Skip to content

Commit 0c5a030

Browse files
committed
fix(connectors): keep per-member listings complete across partial scopes and blank caps
Teams, SharePoint, and OneDrive skip one of several channels or a descendant folder a member cannot reach instead of failing the whole listing, which the members-mode crawl would read as the member reaching nothing. Gmail and Outlook keep their default cap for a null, empty, or whitespace field rather than lifting it, through a shared parseDefaultedUnlimitedSafeInteger. The Confluence incremental CQL clause is fixed on the first page so a cursor is never paired with a query that crossed a minute boundary.
1 parent 8dabb98 commit 0c5a030

15 files changed

Lines changed: 491 additions & 61 deletions

File tree

‎apps/sim/connectors/confluence/confluence.test.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55
import {
66
AtlassianSiteNotAccessibleError,
77
AtlassianSiteNotMatchedError,
@@ -408,3 +408,66 @@ describe('preserveConfluenceCallouts', () => {
408408
expect(result).toContain('[WARNING] Do NOT use this form for: GitLab')
409409
})
410410
})
411+
412+
describe('confluence incremental CQL listing', () => {
413+
const fetchMock =
414+
vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise<Response>>()
415+
416+
function jsonResponse(body: unknown): Response {
417+
return new Response(JSON.stringify(body), {
418+
status: 200,
419+
headers: { 'Content-Type': 'application/json' },
420+
})
421+
}
422+
423+
function cqlOfCall(index: number): string | null {
424+
return new URL(String(fetchMock.mock.calls[index][0])).searchParams.get('cql')
425+
}
426+
427+
beforeEach(() => {
428+
vi.useFakeTimers()
429+
fetchMock.mockReset()
430+
vi.stubGlobal('fetch', fetchMock)
431+
})
432+
433+
afterEach(() => {
434+
vi.unstubAllGlobals()
435+
vi.useRealTimers()
436+
})
437+
438+
it('keeps one lastModified clause across pages that straddle a minute boundary', async () => {
439+
const lastSyncAt = new Date('2026-09-01T11:30:00Z')
440+
const config = { domain: 'example.atlassian.net', spaceKey: 'ENG' }
441+
const syncContext: Record<string, unknown> = { cloudId: 'cloud-1' }
442+
fetchMock
443+
.mockResolvedValueOnce(
444+
jsonResponse({
445+
results: [],
446+
_links: { next: '/wiki/rest/api/content/search?cursor=page-2&cql=ignored' },
447+
})
448+
)
449+
.mockResolvedValueOnce(jsonResponse({ results: [] }))
450+
451+
vi.setSystemTime(new Date('2026-09-01T12:00:59Z'))
452+
const first = await confluenceConnector.listDocuments(
453+
'token',
454+
config,
455+
undefined,
456+
syncContext,
457+
lastSyncAt
458+
)
459+
expect(first.nextCursor).toBe('page-2')
460+
461+
vi.setSystemTime(new Date('2026-09-01T12:01:01Z'))
462+
await confluenceConnector.listDocuments(
463+
'token',
464+
config,
465+
first.nextCursor,
466+
syncContext,
467+
lastSyncAt
468+
)
469+
470+
expect(cqlOfCall(0)).toContain('lastModified >= now("-31m")')
471+
expect(cqlOfCall(1)).toBe(cqlOfCall(0))
472+
})
473+
})

‎apps/sim/connectors/confluence/confluence.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,23 @@ export function buildLastModifiedClause(lastSyncAt: Date, now: Date): string {
698698
return `lastModified >= now("-${minutes}m")`
699699
}
700700

701+
/**
702+
* The `lastModified` clause every page of one listing shares. The clause is a
703+
* window relative to the server clock, so recomputing it on a later page that
704+
* crosses a minute boundary would pair the cursor `_links.next` issued with a
705+
* query it was not issued for; the first page fixes it for the run.
706+
*/
707+
export function resolveLastModifiedClause(
708+
lastSyncAt: Date,
709+
syncContext: Record<string, unknown> | undefined
710+
): string {
711+
const fixed = syncContext?.cqlLastModifiedClause
712+
if (typeof fixed === 'string') return fixed
713+
const clause = buildLastModifiedClause(lastSyncAt, new Date())
714+
if (syncContext) syncContext.cqlLastModifiedClause = clause
715+
return clause
716+
}
717+
701718
/**
702719
* Page size for CQL search. The endpoint defaults to 25 and documents no hard
703720
* maximum, so this stays conservatively below the fixed system limits it warns
@@ -749,7 +766,7 @@ async function listDocumentsViaCql(
749766
cql += ` AND label in (${labelList})`
750767
}
751768

752-
if (lastSyncAt) cql += ` AND ${buildLastModifiedClause(lastSyncAt, new Date())}`
769+
if (lastSyncAt) cql += ` AND ${resolveLastModifiedClause(lastSyncAt, syncContext)}`
753770

754771
const fetchedSoFar = (syncContext?.totalDocsFetched as number) ?? 0
755772
const remaining = maxPages > 0 ? maxPages - fetchedSoFar : Number.POSITIVE_INFINITY

‎apps/sim/connectors/gmail/gmail.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
1212
vi.mock('@/components/icons', () => ({ GmailIcon: () => null }))
1313

1414
import { gmailConnector } from '@/connectors/gmail/gmail'
15+
import { DEFAULT_MAX_THREADS } from '@/connectors/gmail/meta'
1516

1617
function threads(count: number, prefix: string) {
1718
return Array.from({ length: count }, (_, i) => ({ id: `${prefix}-${i}`, historyId: '1' }))
@@ -86,3 +87,20 @@ describe('gmail listDocuments with maxThreads 0 (unlimited, a per-member sync)',
8687
expect(syncContext.listingCapped).toBe(true)
8788
})
8889
})
90+
91+
describe('gmail listDocuments with a blank maxThreads', () => {
92+
it.each([null, '', ' '])('keeps the default cap for %j', async (maxThreads) => {
93+
mockPages([])
94+
const syncContext: Record<string, unknown> = { totalThreadsFetched: DEFAULT_MAX_THREADS }
95+
96+
const result = await gmailConnector.listDocuments(
97+
'token',
98+
{ maxThreads },
99+
undefined,
100+
syncContext
101+
)
102+
103+
expect(result.hasMore).toBe(false)
104+
expect(mockFetchWithRetry).not.toHaveBeenCalled()
105+
})
106+
})

‎apps/sim/connectors/gmail/gmail.ts‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/
66
import {
77
htmlToPlainText,
88
joinTagArray,
9+
parseDefaultedUnlimitedSafeInteger,
910
parseMultiValue,
10-
parseOptionalUnlimitedSafeInteger,
1111
parseTagDate,
1212
} from '@/connectors/utils'
1313

@@ -452,14 +452,12 @@ export const gmailConnector: ConnectorConfig = {
452452
labelIndex = resolved
453453
}
454454
const searchQuery = buildSearchQuery(sourceConfig, labelIndex)
455-
/** Absent means the default cap; an explicit 0 (a per-member sync) means unlimited. */
456-
const maxThreads =
457-
sourceConfig.maxThreads === undefined
458-
? DEFAULT_MAX_THREADS
459-
: parseOptionalUnlimitedSafeInteger(
460-
sourceConfig.maxThreads,
461-
'maxThreads must be a non-negative integer'
462-
)
455+
/** A blank field keeps the default cap; an explicit 0 (a per-member sync) means unlimited. */
456+
const maxThreads = parseDefaultedUnlimitedSafeInteger(
457+
sourceConfig.maxThreads,
458+
DEFAULT_MAX_THREADS,
459+
'maxThreads must be a non-negative integer'
460+
)
463461

464462
const totalFetched = (syncContext?.totalThreadsFetched as number) ?? 0
465463
if (maxThreads > 0 && totalFetched >= maxThreads) {

‎apps/sim/connectors/microsoft-excel/meta.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export const microsoftExcelConnectorMeta: ConnectorMeta = {
1414
requiredScopes: ['Files.ReadWrite'],
1515
},
1616

17-
/** Every worksheet of the one workbook is listed; nothing caps the listing. */
17+
/**
18+
* No config field caps the listing: every worksheet of the one workbook is
19+
* listed. The `MAX_WORKSHEETS` memory bound flags `listingCapped` when it
20+
* bites, which the members-mode crawl reads as an incomplete listing.
21+
*/
1822
permissionScopedListing: { capFieldIds: [] },
1923
configFields: [
2024
{

‎apps/sim/connectors/microsoft-teams/microsoft-teams.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
1212
vi.mock('@/components/icons', () => ({ MicrosoftTeamsIcon: () => null }))
1313

1414
import { microsoftTeamsConnector } from '@/connectors/microsoft-teams/microsoft-teams'
15+
import { PER_MEMBER_LISTING_CONTEXT } from '@/connectors/utils'
1516

1617
const GRAPH = 'https://graph.microsoft.com/v1.0'
1718
const TEAM_ID = 'team-1'
@@ -80,3 +81,76 @@ describe('microsoft teams listing scope', () => {
8081
expect(microsoftTeamsConnector.isListingScopeUnavailableError!(error)).toBe(false)
8182
})
8283
})
84+
85+
describe('microsoft teams per-member listing of several channels', () => {
86+
const messagesUrl = (channelId: string) =>
87+
`${GRAPH}/teams/${TEAM_ID}/channels/${channelId}/messages?$top=50&$expand=replies`
88+
89+
function teamsMessage(id: string) {
90+
return {
91+
id,
92+
messageType: 'message',
93+
createdDateTime: '2026-01-01T00:00:00Z',
94+
from: { user: { id: 'u1', displayName: 'Ada' } },
95+
body: { contentType: 'text', content: `hello from ${id}` },
96+
}
97+
}
98+
99+
/** General is readable, Private answers 403 on its messages, Secret is not listed at all. */
100+
function mockChannels() {
101+
mockGraph({
102+
[CHANNELS_URL]: {
103+
body: {
104+
value: [
105+
{ id: 'c1', displayName: 'General' },
106+
{ id: 'c2', displayName: 'Private' },
107+
],
108+
},
109+
},
110+
[messagesUrl('c1')]: { body: { value: [teamsMessage('m1')] } },
111+
[messagesUrl('c2')]: { status: 403, body: {} },
112+
})
113+
}
114+
115+
beforeEach(() => {
116+
vi.clearAllMocks()
117+
})
118+
119+
it('skips the channels the member cannot reach and keeps the rest', async () => {
120+
mockChannels()
121+
122+
const result = await microsoftTeamsConnector.listDocuments(
123+
'token',
124+
{ teamId: TEAM_ID, channel: ['General', 'Private', 'Secret'] },
125+
undefined,
126+
{ ...PER_MEMBER_LISTING_CONTEXT }
127+
)
128+
129+
expect(result.documents.map((doc) => doc.externalId)).toEqual(['c1'])
130+
expect(result.hasMore).toBe(false)
131+
})
132+
133+
it('still fails a shared listing when one of several channels is unreachable', async () => {
134+
mockChannels()
135+
136+
const error = await microsoftTeamsConnector
137+
.listDocuments('token', { teamId: TEAM_ID, channel: ['General', 'Private'] }, undefined, {})
138+
.catch((e: unknown) => e)
139+
140+
expect(error).toBeInstanceOf(Error)
141+
expect(microsoftTeamsConnector.isListingScopeUnavailableError!(error)).toBe(true)
142+
})
143+
144+
it('reads a sole unreachable channel as the whole scope', async () => {
145+
mockChannels()
146+
147+
const error = await microsoftTeamsConnector
148+
.listDocuments('token', { teamId: TEAM_ID, channel: 'Private' }, undefined, {
149+
...PER_MEMBER_LISTING_CONTEXT,
150+
})
151+
.catch((e: unknown) => e)
152+
153+
expect(error).toBeInstanceOf(Error)
154+
expect(microsoftTeamsConnector.isListingScopeUnavailableError!(error)).toBe(true)
155+
})
156+
})

0 commit comments

Comments
 (0)