@@ -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 */
244243async 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 */
293293async 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