Skip to content

Commit a677ae8

Browse files
committed
improvement(confluence): stream paginated space selector results
Bake pagination support into the selector abstraction via an opt-in fetchPage definition so dropdowns populate progressively instead of blocking on a full page-walk. Confluence spaces now stream current then archived in a single cursor sequence.
1 parent 19f5b2b commit a677ae8

6 files changed

Lines changed: 205 additions & 86 deletions

File tree

apps/sim/app/api/tools/confluence/selector-spaces/route.ts

Lines changed: 57 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,33 @@ const logger = createLogger('ConfluenceSelectorSpacesAPI')
1919

2020
export const dynamic = 'force-dynamic'
2121

22+
const PAGE_LIMIT = 250
23+
24+
type SpaceStatus = 'current' | 'archived'
25+
26+
/**
27+
* Cursor format: `<status>:<innerCursor>`. Empty inner cursor means "first page
28+
* of that status". When current is exhausted we hand back `archived:` so the
29+
* client transparently flips to the archived stream — listing both surfaces
30+
* archived spaces in the dropdown, which would otherwise only be reachable by
31+
* typing the space key manually even though sync works against archived spaces.
32+
*/
33+
function parseCursor(raw: string | undefined): { status: SpaceStatus; inner?: string } {
34+
if (!raw) return { status: 'current' }
35+
const idx = raw.indexOf(':')
36+
if (idx === -1) return { status: 'current' }
37+
const status = raw.slice(0, idx) === 'archived' ? 'archived' : 'current'
38+
const inner = raw.slice(idx + 1)
39+
return { status, inner: inner || undefined }
40+
}
41+
2242
export const POST = withRouteHandler(async (request: NextRequest) => {
2343
const requestId = generateRequestId()
2444
try {
2545
const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {})
2646
if (!parsed.success) return parsed.response
2747

28-
const { credential, workflowId, domain } = parsed.data.body
48+
const { credential, workflowId, domain, cursor } = parsed.data.body
2949

3050
if (!credential) {
3151
logger.error('Missing credential in request')
@@ -44,11 +64,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4464
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
4565
}
4666

47-
// Resolve once so we know whether this is an Atlassian SA credential before
48-
// doing any token / cloudId work. Atlassian SAs short-circuit the entire path:
49-
// the API token IS the access token, and cloudId lives in the encrypted secret —
50-
// so we skip refreshAccessTokenIfNeeded (avoids a redundant resolve+decrypt) and
51-
// skip getConfluenceCloudId (which 401s for scoped SA tokens).
5267
const resolved = await resolveOAuthAccountId(credential)
5368
const isAtlassianServiceAccount =
5469
resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId
@@ -84,82 +99,50 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8499
}
85100

86101
const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces`
87-
const PAGE_LIMIT = 250
88-
const MAX_PAGES = 20
89-
90-
/**
91-
* Confluence v2 `/spaces` defaults to `status=current` and treats `status`
92-
* as a single-value enum, so archived spaces never surface from one call.
93-
* Listing both surfaces archived spaces in the dropdown — they would
94-
* otherwise only be reachable by typing the space key manually, even
95-
* though sync works against archived spaces just fine.
96-
*/
97-
async function fetchAllPages(status: 'current' | 'archived'): Promise<{
98-
spaces: { id: string; name: string; key: string; status: string }[]
99-
capped: boolean
100-
}> {
101-
const collected: { id: string; name: string; key: string; status: string }[] = []
102-
let cursor: string | undefined
103-
let pageCount = 0
104-
105-
while (pageCount < MAX_PAGES) {
106-
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
107-
if (cursor) params.set('cursor', cursor)
108-
const url = `${baseUrl}?${params.toString()}`
109-
110-
const response = await fetch(url, {
111-
method: 'GET',
112-
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
113-
})
102+
const { status, inner } = parseCursor(cursor)
114103

115-
if (!response.ok) {
116-
const errorText = await response.text()
117-
throw new Error(
118-
parseAtlassianErrorMessage(response.status, response.statusText, errorText)
119-
)
120-
}
121-
122-
const data = await response.json()
123-
for (const space of data.results || []) {
124-
collected.push({ id: space.id, name: space.name, key: space.key, status })
125-
}
126-
127-
const nextLink = data._links?.next as string | undefined
128-
if (!nextLink) return { spaces: collected, capped: false }
129-
try {
130-
cursor = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
131-
} catch {
132-
cursor = undefined
133-
}
134-
if (!cursor) return { spaces: collected, capped: false }
135-
pageCount += 1
136-
}
104+
const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status })
105+
if (inner) params.set('cursor', inner)
106+
const url = `${baseUrl}?${params.toString()}`
137107

138-
return { spaces: collected, capped: true }
108+
const response = await fetch(url, {
109+
method: 'GET',
110+
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
111+
})
112+
113+
if (!response.ok) {
114+
const errorText = await response.text()
115+
const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText)
116+
logger.error('Confluence API error response', { error: message, status: response.status })
117+
return NextResponse.json({ error: message }, { status: 502 })
139118
}
140119

141-
let currentResult: Awaited<ReturnType<typeof fetchAllPages>>
142-
let archivedResult: Awaited<ReturnType<typeof fetchAllPages>>
143-
try {
144-
;[currentResult, archivedResult] = await Promise.all([
145-
fetchAllPages('current'),
146-
fetchAllPages('archived'),
147-
])
148-
} catch (error) {
149-
logger.error('Confluence API error response', { error: (error as Error).message })
150-
return NextResponse.json({ error: (error as Error).message }, { status: 502 })
120+
const data = await response.json()
121+
const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({
122+
id: space.id,
123+
name: space.name,
124+
key: space.key,
125+
status,
126+
}))
127+
128+
let nextInner: string | undefined
129+
const nextLink = data._links?.next as string | undefined
130+
if (nextLink) {
131+
try {
132+
nextInner = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined
133+
} catch {
134+
nextInner = undefined
135+
}
151136
}
152137

153-
if (currentResult.capped || archivedResult.capped) {
154-
logger.warn('Confluence space listing hit pagination cap', {
155-
cap: MAX_PAGES * PAGE_LIMIT,
156-
currentCount: currentResult.spaces.length,
157-
archivedCount: archivedResult.spaces.length,
158-
})
138+
let nextCursor: string | undefined
139+
if (nextInner) {
140+
nextCursor = `${status}:${nextInner}`
141+
} else if (status === 'current') {
142+
nextCursor = 'archived:'
159143
}
160144

161-
const spaces = [...currentResult.spaces, ...archivedResult.spaces]
162-
return NextResponse.json({ spaces })
145+
return NextResponse.json({ spaces, nextCursor })
163146
} catch (error) {
164147
logger.error('Error listing Confluence spaces:', error)
165148
return NextResponse.json(

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function SelectorCombobox({
5353
const {
5454
data: options = [],
5555
isLoading,
56+
hasMore,
5657
error,
5758
} = useSelectorOptions(selectorKey, {
5859
context: selectorContext,
@@ -67,6 +68,7 @@ export function SelectorCombobox({
6768
Boolean(activeValue) &&
6869
Boolean(missingOptionLabel) &&
6970
!isLoading &&
71+
!hasMore &&
7072
!optionMap.get(activeValue!)
7173
const selectedLabel = activeValue
7274
? hasMissingOption

apps/sim/hooks/selectors/providers/confluence/selectors.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,53 @@ export const confluenceSelectors = {
2222
],
2323
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
2424
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
25+
const credentialId = ensureCredential(context, 'confluence.spaces')
26+
const domain = ensureDomain(context, 'confluence.spaces')
27+
const collected: { id: string; label: string }[] = []
28+
let cursor: string | undefined
29+
do {
30+
const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, {
31+
body: {
32+
credential: credentialId,
33+
workflowId: context.workflowId,
34+
domain,
35+
cursor,
36+
},
37+
signal,
38+
})
39+
for (const space of data.spaces || []) {
40+
collected.push({ id: space.id, label: formatConfluenceSpaceLabel(space) })
41+
}
42+
cursor = data.nextCursor
43+
} while (cursor)
44+
return collected
45+
},
46+
fetchPage: async ({ context, cursor, signal }) => {
2547
const credentialId = ensureCredential(context, 'confluence.spaces')
2648
const domain = ensureDomain(context, 'confluence.spaces')
2749
const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, {
2850
body: {
2951
credential: credentialId,
3052
workflowId: context.workflowId,
3153
domain,
54+
cursor,
3255
},
3356
signal,
3457
})
35-
return (data.spaces || []).map((space) => ({
36-
id: space.id,
37-
label: formatConfluenceSpaceLabel(space),
38-
}))
58+
return {
59+
items: (data.spaces || []).map((space) => ({
60+
id: space.id,
61+
label: formatConfluenceSpaceLabel(space),
62+
})),
63+
nextCursor: data.nextCursor,
64+
}
3965
},
66+
/**
67+
* Resolves a single space label. Hits only the first page — the dropdown's
68+
* `fetchPage` stream populates the options cache for spaces beyond page 1,
69+
* and `useSelectorOptionMap` merges them in. Walking all pages here would
70+
* double API load since the stream is already running in parallel.
71+
*/
4072
fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => {
4173
if (!detailId) return null
4274
const credentialId = ensureCredential(context, 'confluence.spaces')

apps/sim/hooks/selectors/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,26 @@ export interface SelectorQueryArgs {
100100
signal?: AbortSignal
101101
}
102102

103+
export interface SelectorPage {
104+
items: SelectorOption[]
105+
nextCursor?: string
106+
}
107+
108+
export interface SelectorPageArgs extends SelectorQueryArgs {
109+
cursor?: string
110+
}
111+
103112
export interface SelectorDefinition {
104113
key: SelectorKey
105114
contracts?: readonly AnyApiRouteContract[]
106115
getQueryKey: (args: SelectorQueryArgs) => QueryKey
107116
fetchList: (args: SelectorQueryArgs) => Promise<SelectorOption[]>
117+
/**
118+
* Optional. When defined, the selector hook fetches one page at a time and
119+
* auto-drains remaining pages so the dropdown populates progressively.
120+
* Returns `{ items, nextCursor }`; `nextCursor: undefined` ends the stream.
121+
*/
122+
fetchPage?: (args: SelectorPageArgs) => Promise<SelectorPage>
108123
fetchById?: (args: SelectorQueryArgs) => Promise<SelectorOption | null>
109124
enabled?: (args: SelectorQueryArgs) => boolean
110125
staleTime?: number

apps/sim/hooks/selectors/use-selector-query.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,110 @@
1-
import { useMemo } from 'react'
2-
import { useQuery } from '@tanstack/react-query'
1+
import { useEffect, useMemo } from 'react'
2+
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
33
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
44
import { usePersonalEnvironment } from '@/hooks/queries/environment'
55
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
6-
import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types'
6+
import type {
7+
SelectorKey,
8+
SelectorOption,
9+
SelectorPage,
10+
SelectorQueryArgs,
11+
} from '@/hooks/selectors/types'
712

813
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
914
search?: string
1015
detailId?: string
1116
enabled?: boolean
1217
}
1318

14-
export function useSelectorOptions(key: SelectorKey, args: SelectorHookArgs) {
19+
export interface SelectorOptionsResult {
20+
data: SelectorOption[] | undefined
21+
isLoading: boolean
22+
isFetching: boolean
23+
/**
24+
* True while paginated selectors are draining remaining pages in the
25+
* background. Always false for non-paginated selectors.
26+
*/
27+
isFetchingMore: boolean
28+
/**
29+
* True when the paginated selector still has more pages queued. Always false
30+
* for non-paginated selectors.
31+
*/
32+
hasMore: boolean
33+
error: Error | null
34+
}
35+
36+
const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined }
37+
38+
export function useSelectorOptions(
39+
key: SelectorKey,
40+
args: SelectorHookArgs
41+
): SelectorOptionsResult {
1542
const definition = getSelectorDefinition(key)
1643
const queryArgs: SelectorQueryArgs = {
1744
key,
1845
context: args.context,
1946
search: args.search,
2047
}
2148
const isEnabled = args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)
22-
return useQuery<SelectorOption[]>({
49+
const supportsPagination = Boolean(definition.fetchPage)
50+
51+
const flatQuery = useQuery<SelectorOption[]>({
2352
queryKey: definition.getQueryKey(queryArgs),
2453
queryFn: ({ signal }) => definition.fetchList({ ...queryArgs, signal }),
25-
enabled: isEnabled,
54+
enabled: !supportsPagination && isEnabled,
55+
staleTime: definition.staleTime ?? 30_000,
56+
})
57+
58+
const pagedQuery = useInfiniteQuery<SelectorPage>({
59+
queryKey: [...definition.getQueryKey(queryArgs), 'paged'],
60+
queryFn: ({ pageParam, signal }) => {
61+
if (!definition.fetchPage) return Promise.resolve(EMPTY_PAGE)
62+
return definition.fetchPage({
63+
...queryArgs,
64+
cursor: pageParam as string | undefined,
65+
signal,
66+
})
67+
},
68+
getNextPageParam: (last) => last.nextCursor,
69+
initialPageParam: undefined as string | undefined,
70+
enabled: supportsPagination && isEnabled,
2671
staleTime: definition.staleTime ?? 30_000,
2772
})
73+
74+
const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery
75+
useEffect(() => {
76+
if (!supportsPagination) return
77+
if (isError) return
78+
if (hasNextPage && !isFetchingNextPage) {
79+
void fetchNextPage()
80+
}
81+
}, [supportsPagination, hasNextPage, isFetchingNextPage, isError, fetchNextPage])
82+
83+
const pagedOptions = useMemo<SelectorOption[] | undefined>(() => {
84+
if (!supportsPagination) return undefined
85+
if (!pagedQuery.data) return undefined
86+
return pagedQuery.data.pages.flatMap((page) => page.items)
87+
}, [supportsPagination, pagedQuery.data])
88+
89+
if (supportsPagination) {
90+
return {
91+
data: pagedOptions,
92+
isLoading: pagedQuery.isLoading,
93+
isFetching: pagedQuery.isFetching,
94+
isFetchingMore: pagedQuery.isFetchingNextPage,
95+
hasMore: pagedQuery.hasNextPage ?? false,
96+
error: (pagedQuery.error as Error | null) ?? null,
97+
}
98+
}
99+
100+
return {
101+
data: flatQuery.data,
102+
isLoading: flatQuery.isLoading,
103+
isFetching: flatQuery.isFetching,
104+
isFetchingMore: false,
105+
hasMore: false,
106+
error: (flatQuery.error as Error | null) ?? null,
107+
}
28108
}
29109

30110
export function useSelectorOptionDetail(

0 commit comments

Comments
 (0)