Skip to content

Commit 3bfec6e

Browse files
committed
fix(connectors): ado discards foreign-phase cursors; google-forms scans all response pages for change detection
1 parent 4766fb3 commit 3bfec6e

2 files changed

Lines changed: 56 additions & 47 deletions

File tree

apps/sim/connectors/azure-devops/azure-devops.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,7 +1536,15 @@ export const azureDevopsConnector: ConnectorConfig = {
15361536
: cursor?.startsWith('file|')
15371537
? 'file'
15381538
: 'wiki'
1539-
const phase = phases.includes(cursorPhase) ? cursorPhase : phases[0]
1539+
1540+
/**
1541+
* A cursor from a phase that is no longer active (e.g. the content-type
1542+
* config changed) is discarded along with its offsets — otherwise another
1543+
* phase would misparse its tokens as numeric offsets and skip documents.
1544+
*/
1545+
const cursorIsActive = phases.includes(cursorPhase)
1546+
const phase = cursorIsActive ? cursorPhase : phases[0]
1547+
const initialCursor = cursorIsActive ? cursor : undefined
15401548

15411549
/** Lists a single batch for the given phase. The cursor is passed only when it belongs to that phase. */
15421550
const runPhase = (target: SyncPhase, phaseCursor: string | undefined) => {
@@ -1586,7 +1594,7 @@ export const azureDevopsConnector: ConnectorConfig = {
15861594
* across phases.
15871595
*/
15881596
let current: SyncPhase | undefined = phase
1589-
let phaseCursor = cursor
1597+
let phaseCursor = initialCursor
15901598
const documents: ExternalDocument[] = []
15911599

15921600
while (current) {

apps/sim/connectors/google-forms/google-forms.ts

Lines changed: 46 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,11 @@ async function fetchFormStructure(
218218
/**
219219
* Result of fetching a form's responses: the collected responses (capped at
220220
* `MAX_RESPONSES_PER_FORM` for rendering) plus the greatest submission timestamp
221-
* across the first response page.
221+
* across ALL response pages.
222222
*
223223
* `latestSubmittedTime` is tracked separately from the capped `responses` so the
224224
* content hash computed in getDocument stays identical to the one computed during
225-
* listing, which scans the same first page via `fetchLatestResponseTime`. If it
225+
* listing, which scans the same full set via `fetchLatestResponseTime`. If it
226226
* were derived from the capped slice alone, a form with more than
227227
* `MAX_RESPONSES_PER_FORM` responses could hash differently between the two paths
228228
* and re-sync on every run.
@@ -234,18 +234,16 @@ interface FetchedResponses {
234234

235235
/**
236236
* Fetches form responses, retaining up to `MAX_RESPONSES_PER_FORM` for rendering.
237-
* The latest submission timestamp is derived from the full first page (up to
238-
* `RESPONSES_PAGE_SIZE`) so it matches the change indicator computed during
239-
* listing by `fetchLatestResponseTime`, which reads the same first page. This
240-
* keeps the content hash identical across the listing and getDocument paths even
241-
* when a form has more responses than the render cap. Responses are returned in
242-
* the order provided by the API.
237+
* Every page is scanned for the latest submission timestamp even after the
238+
* render cap is reached — the Forms API does not guarantee response order, so
239+
* the newest submission may sit on any page. `fetchLatestResponseTime` scans
240+
* the same full set during listing, keeping the content hash identical across
241+
* the listing and getDocument paths regardless of form size.
243242
*/
244243
async function fetchFormResponses(accessToken: string, formId: string): Promise<FetchedResponses> {
245244
const collected: FormResponse[] = []
246-
let latestSubmittedTime: string | undefined
245+
let latest = ''
247246
let pageToken: string | undefined
248-
let firstPage = true
249247

250248
do {
251249
const url = new URL(`${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}/responses`)
@@ -267,61 +265,64 @@ async function fetchFormResponses(accessToken: string, formId: string): Promise<
267265
const data = (await response.json()) as FormResponseList
268266
const responses = data.responses ?? []
269267

270-
if (firstPage) {
271-
latestSubmittedTime = latestResponseTime(responses)
272-
firstPage = false
273-
}
268+
const pageLatest = latestResponseTime(responses)
269+
if (pageLatest && pageLatest > latest) latest = pageLatest
274270

275271
for (const r of responses) {
276272
if (collected.length >= MAX_RESPONSES_PER_FORM) break
277273
collected.push(r)
278274
}
279275

280-
pageToken = collected.length >= MAX_RESPONSES_PER_FORM ? undefined : data.nextPageToken
276+
pageToken = data.nextPageToken
281277
} while (pageToken)
282278

283-
return { responses: collected, latestSubmittedTime }
279+
return { responses: collected, latestSubmittedTime: latest || undefined }
284280
}
285281

286282
/**
287283
* Reads the latest response submission time for change detection without
288-
* retaining every response. Returns the greatest `lastSubmittedTime` (falling
289-
* back to `createTime`) across all responses, or undefined when there are none.
290-
* Throws on a failed read so the caller skips the form for this run instead of
291-
* computing a hash from incomplete data.
284+
* retaining responses. Scans every page — the Forms API does not guarantee
285+
* response order, so the newest submission may sit on any page. Returns the
286+
* greatest `lastSubmittedTime` (falling back to `createTime`), or undefined
287+
* when there are none. Throws on a failed read so the caller skips the form
288+
* for this run instead of computing a hash from incomplete data — a swallowed
289+
* error would poison the stub's content hash and re-process the form on every
290+
* sync, while throwing routes into the per-form catch that sets
291+
* `skippedOnError` → `listingCapped`.
292292
*/
293293
async function fetchLatestResponseTime(
294294
accessToken: string,
295295
formId: string
296296
): Promise<string | undefined> {
297-
const url = new URL(`${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}/responses`)
298-
url.searchParams.set('pageSize', String(RESPONSES_PAGE_SIZE))
297+
let latest = ''
298+
let pageToken: string | undefined
299299

300-
const response = await fetchWithRetry(url.toString(), {
301-
method: 'GET',
302-
headers: {
303-
Authorization: `Bearer ${accessToken}`,
304-
Accept: 'application/json',
305-
},
306-
})
300+
do {
301+
const url = new URL(`${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}/responses`)
302+
url.searchParams.set('pageSize', String(RESPONSES_PAGE_SIZE))
303+
if (pageToken) url.searchParams.set('pageToken', pageToken)
307304

308-
if (!response.ok) {
309-
/**
310-
* Propagate the failure rather than hashing with an empty response segment.
311-
* A swallowed error here would poison the stub's content hash (listing
312-
* would hash "no responses" while getDocument hashes the real latest
313-
* submission time), making the form re-process on every sync. Throwing lets
314-
* the per-form catch in listDocuments skip the form for this run and set
315-
* `skippedOnError` → `listingCapped`, so the form is neither deleted nor
316-
* hashed incorrectly.
317-
*/
318-
throw new Error(
319-
`Failed to read responses for change detection on form ${formId}: ${response.status}`
320-
)
321-
}
305+
const response = await fetchWithRetry(url.toString(), {
306+
method: 'GET',
307+
headers: {
308+
Authorization: `Bearer ${accessToken}`,
309+
Accept: 'application/json',
310+
},
311+
})
312+
313+
if (!response.ok) {
314+
throw new Error(
315+
`Failed to read responses for change detection on form ${formId}: ${response.status}`
316+
)
317+
}
322318

323-
const data = (await response.json()) as FormResponseList
324-
return latestResponseTime(data.responses ?? [])
319+
const data = (await response.json()) as FormResponseList
320+
const pageLatest = latestResponseTime(data.responses ?? [])
321+
if (pageLatest && pageLatest > latest) latest = pageLatest
322+
pageToken = data.nextPageToken
323+
} while (pageToken)
324+
325+
return latest || undefined
325326
}
326327

327328
/**

0 commit comments

Comments
 (0)