@kubun/connector-google-calendar 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/provider.js CHANGED
@@ -1,9 +1,11 @@
1
- import { ConnectorError, fetchOrAbsent, parseMaxAgeDays, resolveModelID } from '@kubun/connector';
1
+ import { addBoundaryDuration, ConnectorError, fetchOrAbsent, parseMaxAgeDays, resolveModelID } from '@kubun/connector';
2
2
  import { deriveAccountKey } from './account-key.js';
3
- import { buildCalendarListURL, buildEventsListURL, calendarAPIClient, getCalendarListEntry, getEvent, getEventWithETag, mapCalendarToEntity, mapEventToEntity, splitEventSourceID } from './api.js';
3
+ import { buildCalendarListURL, buildEventsListURL, calendarAPIClient, fetchEventResource, getCalendarListEntry, getEvent, getEventWithETag, mapCalendarToEntity, mapEventToEntity, splitEventSourceID } from './api.js';
4
4
  import { googleCalendarClusters } from './model.js';
5
5
  const CALENDAR_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendar');
6
6
  const CALENDAR_EVENT_MODEL_ID = resolveModelID(googleCalendarClusters, 'GoogleCalendarEvent');
7
+ // How often the forward window rolls by a tokenless full event pass.
8
+ const WINDOW_ROLL_INTERVAL = 'P30D';
7
9
  function computeTimeMin(boundary) {
8
10
  const days = parseMaxAgeDays(boundary);
9
11
  if (days == null) return undefined;
@@ -11,6 +13,56 @@ function computeTimeMin(boundary) {
11
13
  date.setDate(date.getDate() - days);
12
14
  return date.toISOString();
13
15
  }
16
+ // The window of a tokenless full event pass starting now. The lower bound stays
17
+ // frozen once set; the upper bound is recomputed from `maxAhead` but never moves
18
+ // backwards, so a replay from an older checkpoint lists the same events or a
19
+ // superset.
20
+ function fullPassWindow(boundary, prior) {
21
+ const now = new Date();
22
+ const windowStart = prior?.windowStart ?? computeTimeMin(boundary);
23
+ const fresh = boundary.maxAhead != null ? addBoundaryDuration(now, boundary.maxAhead) : null;
24
+ if (fresh == null) {
25
+ return {
26
+ windowStart
27
+ };
28
+ }
29
+ const carried = prior?.windowEnd != null ? new Date(prior.windowEnd) : null;
30
+ const windowEnd = carried != null && carried > fresh ? carried : fresh;
31
+ return {
32
+ windowStart,
33
+ windowEnd: windowEnd.toISOString(),
34
+ windowRolledAt: now.toISOString()
35
+ };
36
+ }
37
+ function isRollDue(boundary, checkpoint) {
38
+ if (boundary.maxAhead == null) return false;
39
+ if (checkpoint.windowEnd == null || checkpoint.windowRolledAt == null) return true;
40
+ const dueAt = addBoundaryDuration(new Date(checkpoint.windowRolledAt), WINDOW_ROLL_INTERVAL);
41
+ return dueAt == null || Date.now() >= dueAt.getTime();
42
+ }
43
+ function windowFields(window) {
44
+ return {
45
+ ...window.windowStart != null ? {
46
+ windowStart: window.windowStart
47
+ } : {},
48
+ ...window.windowEnd != null ? {
49
+ windowEnd: window.windowEnd
50
+ } : {},
51
+ ...window.windowRolledAt != null ? {
52
+ windowRolledAt: window.windowRolledAt
53
+ } : {}
54
+ };
55
+ }
56
+ // Google's `timeMax` is an exclusive bound on the start, applied here to
57
+ // listings that could not send it.
58
+ function startsBefore(windowEnd) {
59
+ if (windowEnd == null) return ()=>true;
60
+ const bound = new Date(windowEnd).getTime();
61
+ return (event)=>{
62
+ const start = event.start.dateTime ?? event.start.date;
63
+ return start == null || new Date(start).getTime() < bound;
64
+ };
65
+ }
14
66
  async function* fetchAllCalendars(params) {
15
67
  let pageToken;
16
68
  let isFirstPage = true;
@@ -32,21 +84,24 @@ async function* fetchAllCalendars(params) {
32
84
  }while (pageToken != null)
33
85
  }
34
86
  async function* fetchEventsForCalendar(params) {
35
- let pageToken;
87
+ let pageToken = params.pageToken;
36
88
  let isFirstPage = true;
37
89
  do {
38
90
  const url = buildEventsListURL({
39
91
  calendarID: params.calendarID,
40
92
  syncToken: isFirstPage ? params.syncToken : undefined,
41
93
  pageToken,
42
- timeMin: params.timeMin
94
+ timeMin: params.timeMin,
95
+ timeMax: params.timeMax,
96
+ showDeleted: params.showDeleted
43
97
  });
44
98
  const data = await params.client.get(url, {
45
99
  signal: params.signal
46
100
  });
47
101
  yield {
48
102
  items: data.items ?? [],
49
- nextSyncToken: data.nextSyncToken
103
+ nextSyncToken: data.nextSyncToken,
104
+ nextPageToken: data.nextPageToken
50
105
  };
51
106
  pageToken = data.nextPageToken;
52
107
  isFirstPage = false;
@@ -62,12 +117,11 @@ export function createGoogleCalendarProvider(config) {
62
117
  // providerAccount capture.
63
118
  const accountKey = config.credential.providerAccount != null ? deriveAccountKey(config.credential.providerAccount) : undefined;
64
119
  async function* fetchAllEntities(params) {
65
- // Reuse a window frozen by a prior sync (carried in an expired checkpoint on
66
- // the 410 resync path, or a re-driven initial) instead of recomputing from
67
- // `now`; only a genuine first sync computes it fresh.
120
+ // Reuse a window frozen by a prior sync when an initial pass is re-driven;
121
+ // only a genuine first sync computes it fresh.
68
122
  const prior = params.checkpoint;
69
- const windowStart = prior?.windowStart ?? computeTimeMin(params.boundary);
70
- const timeMin = windowStart;
123
+ const window = fullPassWindow(params.boundary, prior);
124
+ const inWindow = startsBefore(window.windowEnd);
71
125
  // Fetch all calendars (collect all pages first, then yield single batch)
72
126
  const allCalendars = [];
73
127
  let calendarListSyncToken;
@@ -84,7 +138,7 @@ export function createGoogleCalendarProvider(config) {
84
138
  calendarListSyncToken = page.nextSyncToken;
85
139
  }
86
140
  }
87
- // Yield calendar batch (no checkpoint yet — will be on the last event batch)
141
+ // Event batches checkpoint each completed calendar after this list batch.
88
142
  const calendarListToken = mintFetchHLC();
89
143
  yield {
90
144
  modelID: CALENDAR_MODEL_ID,
@@ -96,23 +150,31 @@ export function createGoogleCalendarProvider(config) {
96
150
  hasMore: true
97
151
  };
98
152
  // Fetch events per calendar
99
- const calendarTokens = {};
153
+ const calendarTokens = Object.fromEntries(allCalendars.map((calendar)=>[
154
+ calendar.id,
155
+ {}
156
+ ]));
100
157
  let eventBatchYielded = false;
101
158
  for (const [i, calendar] of allCalendars.entries()){
102
159
  const isLastCalendar = i === allCalendars.length - 1;
103
160
  let eventsSyncToken;
161
+ const relistID = mintFetchHLC();
162
+ const seen = new Set();
104
163
  for await (const page of fetchEventsForCalendar({
105
164
  calendarID: calendar.id,
106
165
  client,
107
- timeMin,
166
+ timeMin: window.windowStart,
167
+ timeMax: window.windowEnd,
168
+ showDeleted: true,
108
169
  signal: params.signal
109
170
  })){
171
+ for (const event of page.items)seen.add(`${calendar.id}/${event.id}`);
110
172
  if (page.nextSyncToken != null) {
111
173
  eventsSyncToken = page.nextSyncToken;
112
174
  }
113
175
  const isLastPage = page.nextSyncToken != null // last page has nextSyncToken
114
176
  ;
115
- const checkpoint = isLastCalendar && isLastPage ? {
177
+ const checkpoint = isLastPage ? {
116
178
  calendarListSyncToken,
117
179
  calendars: {
118
180
  ...calendarTokens,
@@ -120,12 +182,10 @@ export function createGoogleCalendarProvider(config) {
120
182
  eventsSyncToken: page.nextSyncToken
121
183
  }
122
184
  },
123
- ...windowStart != null ? {
124
- windowStart
125
- } : {}
185
+ ...windowFields(window)
126
186
  } : undefined;
127
187
  // Split items: cancelled → deleted, others → entities
128
- const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
188
+ const activeItems = page.items.filter((e)=>e.status !== 'cancelled' && inWindow(e));
129
189
  const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
130
190
  const pageToken = mintFetchHLC();
131
191
  yield {
@@ -143,7 +203,28 @@ export function createGoogleCalendarProvider(config) {
143
203
  sourceFetchHLC: pageToken
144
204
  })),
145
205
  hasMore: !(isLastCalendar && isLastPage),
146
- checkpoint
206
+ checkpoint,
207
+ ...isLastPage ? {
208
+ sweep: {
209
+ modelID: CALENDAR_EVENT_MODEL_ID,
210
+ scope: {
211
+ calendarID: calendar.id
212
+ },
213
+ partition: calendar.id,
214
+ provenance: relistID,
215
+ complete: true,
216
+ kind: 'relist',
217
+ keep: [
218
+ ...seen
219
+ ],
220
+ sourceFetchHLC: relistID,
221
+ predicate: {
222
+ field: 'startAt',
223
+ from: window.windowStart ?? null,
224
+ to: window.windowEnd ?? null
225
+ }
226
+ }
227
+ } : {}
147
228
  };
148
229
  eventBatchYielded = true;
149
230
  }
@@ -161,147 +242,274 @@ export function createGoogleCalendarProvider(config) {
161
242
  checkpoint: {
162
243
  calendarListSyncToken,
163
244
  calendars: calendarTokens,
164
- ...windowStart != null ? {
165
- windowStart
166
- } : {}
245
+ ...windowFields(window)
167
246
  }
168
247
  };
169
248
  }
170
249
  }
171
250
  async function* fetchIncrementalEntities(params, checkpoint) {
172
- // Carry the frozen window forward; new calendars are floored by it (not `now`).
173
- const windowStart = checkpoint.windowStart ?? computeTimeMin(params.boundary);
174
- const timeMin = windowStart;
175
- // Fetch calendar list changes using stored syncToken
251
+ const resumingRelist = Object.values(checkpoint.calendars).some((calendar)=>calendar.relist != null);
252
+ const rollDue = checkpoint.roll != null || checkpoint.completed == null && !resumingRelist && isRollDue(params.boundary, checkpoint);
253
+ const window = checkpoint.roll != null ? {
254
+ windowStart: checkpoint.windowStart,
255
+ windowEnd: checkpoint.roll.windowEnd,
256
+ windowRolledAt: checkpoint.roll.startedAt
257
+ } : rollDue ? fullPassWindow(params.boundary, checkpoint) : {
258
+ windowStart: checkpoint.windowStart ?? computeTimeMin(params.boundary),
259
+ ...params.boundary.maxAhead != null ? {
260
+ windowEnd: checkpoint.windowEnd,
261
+ windowRolledAt: checkpoint.windowRolledAt
262
+ } : {}
263
+ };
264
+ const roll = checkpoint.roll ?? (rollDue && window.windowEnd != null && window.windowRolledAt != null ? {
265
+ windowEnd: window.windowEnd,
266
+ startedAt: window.windowRolledAt,
267
+ completed: []
268
+ } : undefined);
176
269
  const activeCalendars = [];
177
- const deletedCalendarIDs = [];
270
+ const deletedCalendarIDs = new Set();
271
+ // Every calendar the list reports live, excluded ones included: exclusion
272
+ // is local configuration, never evidence that a calendar was removed.
273
+ const listedCalendarIDs = new Set();
178
274
  let newCalendarListSyncToken;
179
- for await (const page of fetchAllCalendars({
180
- client,
181
- syncToken: checkpoint.calendarListSyncToken,
182
- signal: params.signal
183
- })){
184
- for (const cal of page.items){
185
- if (cal.deleted === true) {
186
- deletedCalendarIDs.push(cal.id);
187
- } else if (!excludeSet.has(cal.id)) {
188
- activeCalendars.push(cal);
275
+ const collectCalendars = async (syncToken)=>{
276
+ activeCalendars.length = 0;
277
+ deletedCalendarIDs.clear();
278
+ listedCalendarIDs.clear();
279
+ for await (const page of fetchAllCalendars({
280
+ client,
281
+ syncToken,
282
+ signal: params.signal
283
+ })){
284
+ for (const cal of page.items){
285
+ if (cal.deleted === true) {
286
+ deletedCalendarIDs.add(cal.id);
287
+ continue;
288
+ }
289
+ listedCalendarIDs.add(cal.id);
290
+ if (!excludeSet.has(cal.id)) activeCalendars.push(cal);
189
291
  }
292
+ if (page.nextSyncToken != null) newCalendarListSyncToken = page.nextSyncToken;
190
293
  }
191
- if (page.nextSyncToken != null) {
192
- newCalendarListSyncToken = page.nextSyncToken;
294
+ };
295
+ try {
296
+ await collectCalendars(checkpoint.calendarListSyncToken);
297
+ } catch (err) {
298
+ if (!(err instanceof ConnectorError && err.httpStatus === 410)) throw err;
299
+ await collectCalendars();
300
+ for (const id of Object.keys(checkpoint.calendars)){
301
+ if (!listedCalendarIDs.has(id)) deletedCalendarIDs.add(id);
193
302
  }
194
303
  }
195
- // Yield calendar batch (active + deletions)
196
304
  const calendarListToken = mintFetchHLC();
197
305
  yield {
198
306
  modelID: CALENDAR_MODEL_ID,
199
- entities: activeCalendars.map((c)=>({
307
+ entities: activeCalendars.map((cal)=>({
200
308
  present: true,
201
- record: mapCalendarToEntity(c, accountKey),
309
+ record: mapCalendarToEntity(cal, accountKey),
202
310
  sourceFetchHLC: calendarListToken
203
311
  })),
204
- deleted: deletedCalendarIDs.map((id)=>({
312
+ deleted: [
313
+ ...deletedCalendarIDs
314
+ ].map((id)=>({
205
315
  present: false,
206
316
  sourceID: id,
207
317
  sourceFetchHLC: calendarListToken
208
318
  })),
209
319
  hasMore: true
210
320
  };
211
- // Determine the set of calendars to sync events for
212
- // Start from the previous calendar set, remove deleted, add newly discovered active ones
213
- const prevCalendarTokens = {
321
+ const calendarTokens = {
214
322
  ...checkpoint.calendars
215
323
  };
324
+ for (const id of deletedCalendarIDs)delete calendarTokens[id];
325
+ for (const cal of activeCalendars){
326
+ if (!(cal.id in calendarTokens)) calendarTokens[cal.id] = {};
327
+ }
328
+ const completed = new Set(checkpoint.completed ?? []);
329
+ const rollCompleted = new Set(roll?.completed ?? []);
330
+ const pendingCalendarIDs = Object.keys(calendarTokens).filter((id)=>!completed.has(id) && !rollCompleted.has(id));
331
+ const checkpointFor = (tokens, complete, completedCalendarID)=>{
332
+ if (completedCalendarID != null) {
333
+ if (roll == null) completed.add(completedCalendarID);
334
+ else rollCompleted.add(completedCalendarID);
335
+ }
336
+ return {
337
+ calendarListSyncToken: newCalendarListSyncToken ?? checkpoint.calendarListSyncToken,
338
+ calendars: tokens,
339
+ ...roll == null && !complete ? {
340
+ completed: [
341
+ ...completed
342
+ ]
343
+ } : {},
344
+ ...roll != null && !complete ? {
345
+ roll: {
346
+ ...roll,
347
+ completed: [
348
+ ...rollCompleted
349
+ ]
350
+ }
351
+ } : {},
352
+ ...windowFields(complete ? window : {
353
+ ...window,
354
+ windowRolledAt: checkpoint.windowRolledAt
355
+ })
356
+ };
357
+ };
358
+ const eventBatch = (calendarID, items, token, hasMore, nextCheckpoint)=>({
359
+ modelID: CALENDAR_EVENT_MODEL_ID,
360
+ // A sync-token delta can move an existing event outside the fetch
361
+ // window. Preserve its document with the new date; only a provider
362
+ // cancellation is an authoritative deletion.
363
+ entities: items.filter((event)=>event.status !== 'cancelled').map((event)=>({
364
+ present: true,
365
+ record: mapEventToEntity(event, calendarID),
366
+ sourceFetchHLC: token
367
+ })),
368
+ deleted: items.filter((event)=>event.status === 'cancelled').map((event)=>({
369
+ present: false,
370
+ sourceID: `${calendarID}/${event.id}`,
371
+ sourceFetchHLC: token
372
+ })),
373
+ hasMore,
374
+ checkpoint: nextCheckpoint
375
+ });
216
376
  for (const deletedID of deletedCalendarIDs){
217
- delete prevCalendarTokens[deletedID];
377
+ yield {
378
+ modelID: CALENDAR_EVENT_MODEL_ID,
379
+ entities: [],
380
+ sweep: {
381
+ modelID: CALENDAR_EVENT_MODEL_ID,
382
+ scope: {
383
+ calendarID: deletedID
384
+ },
385
+ keep: [],
386
+ sourceFetchHLC: calendarListToken,
387
+ partition: deletedID,
388
+ provenance: calendarListToken,
389
+ complete: true,
390
+ predicate: null,
391
+ kind: 'partition-removal'
392
+ },
393
+ hasMore: true
394
+ };
218
395
  }
219
- // Collect new calendars that weren't in the previous checkpoint
220
- const newlyDiscoveredCalendars = activeCalendars.filter((cal)=>!(cal.id in checkpoint.calendars));
221
- // All calendars to sync: known (from prev checkpoint, minus deleted) + newly discovered
222
- const knownCalendarIDs = Object.keys(prevCalendarTokens);
223
- // Combine: known calendar IDs (we don't have resource objects for them, only IDs)
224
- // plus newly discovered calendar resources
225
- const allCalendarIDsToSync = [
226
- ...knownCalendarIDs.map((id)=>({
227
- id,
228
- isNew: false
229
- })),
230
- ...newlyDiscoveredCalendars.map((cal)=>({
231
- id: cal.id,
232
- isNew: true
233
- }))
234
- ];
235
- const updatedCalendarTokens = {};
236
- for (const [i, entry] of allCalendarIDsToSync.entries()){
237
- const { id: calendarID, isNew } = entry;
238
- const isLastCalendar = i === allCalendarIDsToSync.length - 1;
239
- // Use stored syncToken for known calendars; use timeMin for new ones
240
- const storedToken = isNew ? undefined : prevCalendarTokens[calendarID]?.eventsSyncToken;
241
- let eventsSyncToken;
242
- for await (const page of fetchEventsForCalendar({
243
- calendarID,
244
- client,
245
- syncToken: storedToken,
246
- timeMin: isNew ? timeMin : undefined,
247
- signal: params.signal
248
- })){
249
- if (page.nextSyncToken != null) {
250
- eventsSyncToken = page.nextSyncToken;
396
+ for (const [index, calendarID] of pendingCalendarIDs.entries()){
397
+ const prior = calendarTokens[calendarID];
398
+ const storedToken = prior?.eventsSyncToken;
399
+ const relist = prior?.relist;
400
+ if (rollDue && storedToken != null && relist == null) {
401
+ try {
402
+ for await (const page of fetchEventsForCalendar({
403
+ calendarID,
404
+ client,
405
+ syncToken: storedToken,
406
+ signal: params.signal
407
+ })){
408
+ yield eventBatch(calendarID, page.items, mintFetchHLC(), true);
409
+ }
410
+ } catch (err) {
411
+ if (!(err instanceof ConnectorError && err.httpStatus === 410)) throw err;
251
412
  }
252
- const isLastPage = page.nextSyncToken != null;
253
- const newCheckpoint = isLastCalendar && isLastPage ? {
254
- calendarListSyncToken: newCalendarListSyncToken,
255
- calendars: {
256
- ...updatedCalendarTokens,
257
- [calendarID]: {
413
+ }
414
+ const list = async function*(syncToken, currentRelist) {
415
+ let tracking = currentRelist;
416
+ if (syncToken == null && tracking == null) {
417
+ const startedFetchHLC = mintFetchHLC();
418
+ tracking = {
419
+ relistID: startedFetchHLC,
420
+ startedFetchHLC,
421
+ seen: []
422
+ };
423
+ }
424
+ for await (const page of fetchEventsForCalendar({
425
+ calendarID,
426
+ client,
427
+ syncToken,
428
+ pageToken: tracking?.pageToken,
429
+ timeMin: syncToken == null ? window.windowStart : undefined,
430
+ timeMax: syncToken == null ? window.windowEnd : undefined,
431
+ showDeleted: syncToken == null ? true : undefined,
432
+ signal: params.signal
433
+ })){
434
+ const token = mintFetchHLC();
435
+ const seen = tracking == null ? [] : [
436
+ ...new Set([
437
+ ...tracking.seen,
438
+ ...page.items.map((item)=>`${calendarID}/${item.id}`)
439
+ ])
440
+ ];
441
+ const finalPage = page.nextSyncToken != null;
442
+ const complete = index === pendingCalendarIDs.length - 1 && finalPage;
443
+ const nextTokens = {
444
+ ...calendarTokens,
445
+ [calendarID]: finalPage ? {
258
446
  eventsSyncToken: page.nextSyncToken
447
+ } : {
448
+ ...syncToken == null ? {} : {
449
+ eventsSyncToken: syncToken
450
+ },
451
+ relist: tracking == null ? undefined : {
452
+ ...tracking,
453
+ pageToken: page.nextPageToken,
454
+ seen
455
+ }
259
456
  }
260
- },
261
- ...windowStart != null ? {
262
- windowStart
263
- } : {}
264
- } : undefined;
265
- // Split items: cancelled → deleted, others → entities
266
- const activeItems = page.items.filter((e)=>e.status !== 'cancelled');
267
- const cancelledItems = page.items.filter((e)=>e.status === 'cancelled');
268
- const pageToken = mintFetchHLC();
269
- yield {
270
- modelID: CALENDAR_EVENT_MODEL_ID,
271
- entities: activeItems.map((event)=>({
272
- present: true,
273
- record: mapEventToEntity(event, calendarID),
274
- sourceFetchHLC: pageToken
275
- })),
276
- // Match the composite identity mapEventToEntity assigns, so the
277
- // tombstone lookup hits the calendar-namespaced document.
278
- deleted: cancelledItems.map((e)=>({
279
- present: false,
280
- sourceID: `${calendarID}/${e.id}`,
281
- sourceFetchHLC: pageToken
282
- })),
283
- hasMore: !(isLastCalendar && isLastPage),
284
- checkpoint: newCheckpoint
285
- };
286
- }
287
- updatedCalendarTokens[calendarID] = {
288
- eventsSyncToken
457
+ };
458
+ const batch = eventBatch(calendarID, page.items, token, !complete, tracking != null || finalPage ? checkpointFor(nextTokens, complete, finalPage ? calendarID : undefined) : undefined);
459
+ if (tracking != null && finalPage) {
460
+ batch.sweep = {
461
+ modelID: CALENDAR_EVENT_MODEL_ID,
462
+ scope: {
463
+ calendarID
464
+ },
465
+ keep: seen,
466
+ sourceFetchHLC: tracking.startedFetchHLC,
467
+ partition: calendarID,
468
+ provenance: tracking.relistID,
469
+ complete: true,
470
+ predicate: {
471
+ field: 'startAt',
472
+ from: window.windowStart ?? null,
473
+ to: window.windowEnd ?? null
474
+ },
475
+ kind: 'relist'
476
+ };
477
+ }
478
+ yield batch;
479
+ if (tracking != null) tracking = {
480
+ ...tracking,
481
+ pageToken: page.nextPageToken,
482
+ seen
483
+ };
484
+ if (finalPage) calendarTokens[calendarID] = {
485
+ eventsSyncToken: page.nextSyncToken
486
+ };
487
+ }
289
488
  };
489
+ if (relist != null) {
490
+ try {
491
+ yield* list(undefined, relist);
492
+ } catch (err) {
493
+ if (!(err instanceof ConnectorError && err.httpStatus === 410)) throw err;
494
+ yield* list(undefined, undefined);
495
+ }
496
+ } else if (rollDue || storedToken == null) {
497
+ yield* list(undefined, undefined);
498
+ } else {
499
+ try {
500
+ yield* list(storedToken, undefined);
501
+ } catch (err) {
502
+ if (!(err instanceof ConnectorError && err.httpStatus === 410)) throw err;
503
+ yield* list(undefined, undefined);
504
+ }
505
+ }
290
506
  }
291
- // If no event batches were yielded (zero calendars to sync), emit a final empty
292
- // batch with hasMore: false and the composite checkpoint so the sync can complete.
293
- if (allCalendarIDsToSync.length === 0) {
507
+ if (pendingCalendarIDs.length === 0) {
294
508
  yield {
295
509
  modelID: CALENDAR_EVENT_MODEL_ID,
296
510
  entities: [],
297
511
  hasMore: false,
298
- checkpoint: {
299
- calendarListSyncToken: newCalendarListSyncToken,
300
- calendars: updatedCalendarTokens,
301
- ...windowStart != null ? {
302
- windowStart
303
- } : {}
304
- }
512
+ checkpoint: checkpointFor(calendarTokens, true)
305
513
  };
306
514
  }
307
515
  }
@@ -339,6 +547,71 @@ export function createGoogleCalendarProvider(config) {
339
547
  }
340
548
  });
341
549
  }
550
+ async function verifyEntity(model, sourceID) {
551
+ const sourceFetchHLC = mintFetchHLC();
552
+ if (model !== CALENDAR_EVENT_MODEL_ID) {
553
+ try {
554
+ return await fetchEntity(model, sourceID);
555
+ } catch {
556
+ return {
557
+ outcome: 'uncertain',
558
+ sourceFetchHLC
559
+ };
560
+ }
561
+ }
562
+ try {
563
+ const { calendarID, eventID } = splitEventSourceID(sourceID);
564
+ try {
565
+ const event = await fetchEventResource({
566
+ accessToken: config.credential.accessToken,
567
+ calendarID,
568
+ eventID,
569
+ fetch: fetchFn
570
+ });
571
+ if (event.status === 'cancelled') return {
572
+ outcome: 'cancelled',
573
+ sourceFetchHLC
574
+ };
575
+ return {
576
+ present: true,
577
+ record: mapEventToEntity(event, calendarID),
578
+ sourceFetchHLC
579
+ };
580
+ } catch (error) {
581
+ if (!(error instanceof ConnectorError && error.httpStatus === 404)) {
582
+ return {
583
+ outcome: 'uncertain',
584
+ sourceFetchHLC
585
+ };
586
+ }
587
+ try {
588
+ const calendar = await getCalendarListEntry({
589
+ accessToken: config.credential.accessToken,
590
+ calendarID,
591
+ fetch: fetchFn
592
+ });
593
+ return calendar.deleted === true ? {
594
+ outcome: 'uncertain',
595
+ sourceFetchHLC
596
+ } : {
597
+ present: false,
598
+ sourceID,
599
+ sourceFetchHLC
600
+ };
601
+ } catch {
602
+ return {
603
+ outcome: 'uncertain',
604
+ sourceFetchHLC
605
+ };
606
+ }
607
+ }
608
+ } catch {
609
+ return {
610
+ outcome: 'uncertain',
611
+ sourceFetchHLC
612
+ };
613
+ }
614
+ }
342
615
  // Provider-authoritative re-read for write-back recovery. Unlike
343
616
  // fetchEntity, the present:true branch also surfaces a providerETag when the
344
617
  // provider reports one — the current revision an update's If-Match can
@@ -379,6 +652,7 @@ export function createGoogleCalendarProvider(config) {
379
652
  ],
380
653
  fetchAll: fetchAllEntities,
381
654
  fetchEntity,
655
+ verifyEntity,
382
656
  reconcile,
383
657
  async *fetchChanges (params) {
384
658
  const checkpoint = params.checkpoint;
@@ -386,17 +660,7 @@ export function createGoogleCalendarProvider(config) {
386
660
  yield* fetchAllEntities(params);
387
661
  return;
388
662
  }
389
- try {
390
- yield* fetchIncrementalEntities(params, checkpoint);
391
- } catch (err) {
392
- // 410 Gone = the stored syncToken has expired; Google requires a full
393
- // resync. Discard the checkpoint by delegating to fetchAll.
394
- if (err instanceof ConnectorError && err.httpStatus === 410) {
395
- yield* fetchAllEntities(params);
396
- return;
397
- }
398
- throw err;
399
- }
663
+ yield* fetchIncrementalEntities(params, checkpoint);
400
664
  }
401
665
  };
402
666
  }