@wirecat/cli-core 0.487.0 → 0.489.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/dist/calendar/cli/index.js +48 -13
  3. package/dist/calendar/index.d.ts +1 -1
  4. package/dist/calendar/mcp/index.d.ts +390 -0
  5. package/dist/calendar/mcp/index.js +50 -12
  6. package/dist/calendar/reads.d.ts +9 -2
  7. package/dist/calendar/reads.js +60 -5
  8. package/dist/calendar/recurrence.d.ts +20 -0
  9. package/dist/calendar/recurrence.js +74 -0
  10. package/dist/calendar/sync.d.ts +6 -0
  11. package/dist/calendar/sync.js +123 -41
  12. package/dist/calendar/testing/memory.js +90 -7
  13. package/dist/calendar/testing/sync-contract.js +90 -0
  14. package/dist/calendar/testing/writer-contract.d.ts +2 -6
  15. package/dist/calendar/testing/writer-contract.js +157 -29
  16. package/dist/calendar/testing/writer.d.ts +3 -1
  17. package/dist/calendar/testing/writer.js +11 -0
  18. package/dist/calendar/writer.d.ts +20 -3
  19. package/dist/calendar/writer.js +8 -4
  20. package/dist/calendar/writes.d.ts +21 -12
  21. package/dist/calendar/writes.js +259 -20
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +5 -0
  24. package/dist/mail/attachments.d.ts +15 -0
  25. package/dist/mail/attachments.js +38 -0
  26. package/dist/mail/locations.d.ts +28 -0
  27. package/dist/mail/locations.js +54 -0
  28. package/dist/mail/quick.d.ts +3 -1
  29. package/dist/mail/quick.js +9 -6
  30. package/dist/mail/resume.d.ts +6 -0
  31. package/dist/mail/retry.d.ts +23 -0
  32. package/dist/mail/retry.js +110 -0
  33. package/dist/mail/sync.d.ts +2 -3
  34. package/dist/mail/sync.js +85 -152
  35. package/dist/mail/testing/fake.d.ts +2 -0
  36. package/dist/mail/testing/fake.js +1 -1
  37. package/dist/meetings/event-store.d.ts +80 -2
  38. package/dist/meetings/event-store.js +65 -1
  39. package/dist/meetings/index.d.ts +2 -1
  40. package/dist/meetings/index.js +1 -0
  41. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  42. package/dist/meetings/testing/event-store-contract.js +114 -0
  43. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  44. package/dist/store/drivers/node-sqlite.js +30 -8
  45. package/dist/store/index.d.ts +1 -1
  46. package/dist/store/sqlite/accounts.js +1 -0
  47. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  48. package/dist/store/sqlite/calendar-events.js +89 -0
  49. package/dist/store/sqlite/calendar.js +126 -94
  50. package/dist/store/sqlite/email-locations.d.ts +24 -0
  51. package/dist/store/sqlite/email-locations.js +56 -0
  52. package/dist/store/sqlite/email-state.d.ts +2 -0
  53. package/dist/store/sqlite/email-state.js +28 -0
  54. package/dist/store/sqlite/emails.d.ts +13 -0
  55. package/dist/store/sqlite/emails.js +9 -29
  56. package/dist/store/sqlite/manifest.js +1 -0
  57. package/dist/store/sqlite/migrations.generated.js +7 -0
  58. package/dist/store/sqlite/schema.d.ts +72 -0
  59. package/dist/store/sqlite/schema.js +13 -0
  60. package/package.json +1 -1
@@ -3,6 +3,14 @@ import { runBudget } from "../background/budget.js";
3
3
  import { progressLine, progressOf } from "../background/checkpoints.js";
4
4
  import { isCliFailure } from "../errors/failures.js";
5
5
  const DAY = 86_400_000;
6
+ /**
7
+ * How far the window's far end may move before a full round asks every series for its slots again. In between it asks
8
+ * only the series that changed, so a series that did not change lacks slots up to this far behind the window's end.
9
+ */
10
+ const SLOTS_EVERY = 7 * DAY;
11
+ /** Series whose slots one transaction saves: a short one, so another process waits for the lock briefly. */
12
+ const BATCH_SERIES = 50;
13
+ const BATCH_SLOTS = 250;
6
14
  const isWindow = (value) => typeof value?.from === "number" && typeof value.to === "number";
7
15
  const isToken = (value) => value === null || typeof value === "string";
8
16
  const roundOf = (value) => {
@@ -18,10 +26,22 @@ const roundOf = (value) => {
18
26
  typeof round.listed !== "boolean" ||
19
27
  !isToken(round.next) ||
20
28
  !Array.isArray(round.series) ||
21
- round.series.some((id) => typeof id !== "string"))
29
+ round.series.some((id) => typeof id !== "string") ||
30
+ (round.everySeries !== undefined && typeof round.everySeries !== "boolean"))
22
31
  return undefined;
23
- const { full, from, window, startedAt, reset, page, listed, next, series } = round;
24
- return { full, from, window, startedAt, reset, page, listed, next, series };
32
+ const { full, from, window, startedAt, reset, page, listed, next, series, everySeries } = round;
33
+ return {
34
+ full,
35
+ from,
36
+ window,
37
+ startedAt,
38
+ reset,
39
+ page,
40
+ listed,
41
+ next,
42
+ series,
43
+ ...(everySeries === false ? { everySeries } : {}),
44
+ };
25
45
  };
26
46
  export const parseCalendarCursor = (value) => {
27
47
  if (value === null)
@@ -41,7 +61,17 @@ export const parseCalendarCursor = (value) => {
41
61
  (cursor.updatedAt !== null && typeof cursor.updatedAt !== "number") ||
42
62
  typeof cursor.done !== "number")
43
63
  return null;
44
- return { v: 2, token, window, fullAt, updatedAt: cursor.updatedAt ?? null, done: cursor.done, round };
64
+ return {
65
+ v: 2,
66
+ token,
67
+ window,
68
+ fullAt,
69
+ updatedAt: cursor.updatedAt ?? null,
70
+ done: cursor.done,
71
+ round,
72
+ ...(cursor.restored === true ? { restored: true } : {}),
73
+ ...(typeof cursor.slotsTo === "number" ? { slotsTo: cursor.slotsTo } : {}),
74
+ };
45
75
  }
46
76
  catch {
47
77
  return null;
@@ -57,7 +87,7 @@ export const calendarSyncWindow = (now) => {
57
87
  return { from: Date.UTC(year - 1, month, date), to: Date.UTC(year, month + 3, date) };
58
88
  };
59
89
  const CONTINUE = "run sync again to continue";
60
- const freshRound = (full, from, window, now, reset = false) => ({
90
+ const freshRound = (full, from, window, now, reset = false, everySeries = true) => ({
61
91
  full,
62
92
  from: full ? null : from,
63
93
  window,
@@ -67,28 +97,29 @@ const freshRound = (full, from, window, now, reset = false) => ({
67
97
  listed: false,
68
98
  next: null,
69
99
  series: [],
100
+ ...(everySeries ? {} : { everySeries }),
70
101
  });
71
- /** The round in progress, or the one this run starts: a full one when asked, due, or the only kind the source has. */
72
- const roundFor = (source, stored, { asked, window, now }) => {
73
- if (stored?.round && !asked)
102
+ /** The round in progress, or the one this run starts: a full one when due or the only kind the source has. */
103
+ const roundFor = (source, stored, { window, now }) => {
104
+ if (stored?.round)
74
105
  return stored.round;
75
106
  const windowMoved = stored !== null && (stored.window.from !== window.from || stored.window.to !== window.to);
76
- // A full round once a day also asks every series for its slots again, which an incremental round does only for
77
- // the series that changed (foundation §6.2 step 7).
78
- const full = asked ||
79
- source.traits.fullListOnly ||
107
+ const full = source.traits.fullListOnly ||
80
108
  stored === null ||
81
109
  stored.token === null ||
82
110
  (source.traits.cursorHoldsWindow && windowMoved) ||
83
111
  now - stored.fullAt >= DAY;
84
- return freshRound(full, stored?.token ?? null, window, now);
112
+ // The daily full round asks only the series that changed, and every series once the window's end moved on far
113
+ // enough that their new slots matter (foundation §6.2 step 7).
114
+ const everySeries = !full || stored?.slotsTo === undefined || window.to - stored.slotsTo >= SLOTS_EVERY;
115
+ return freshRound(full, stored?.token ?? null, window, now, false, everySeries);
85
116
  };
86
117
  const deadList = (provider) => new CliError("invalid_response", `${provider} refused a full list as a dead cursor`);
87
118
  /**
88
119
  * One calendar's round, saved after every page and every series, so a stop — the budget, a cancel, a kill — loses
89
120
  * at most the step it was on. Returns why it stopped, or `null` when the round finished.
90
121
  */
91
- const syncOne = async ({ source, store, calendar, window, now, expand, budget, signal, moved }, stored, asked, result) => {
122
+ const syncOne = async ({ source, store, calendar, window, now, expand, budget, signal, moved }, stored, result) => {
92
123
  let cursor = stored ?? {
93
124
  v: 2,
94
125
  token: null,
@@ -97,8 +128,14 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
97
128
  updatedAt: null,
98
129
  done: 0,
99
130
  round: null,
131
+ restored: true,
100
132
  };
101
- let round = roundFor(source, stored, { asked, window, now });
133
+ if (stored !== null && !stored.restored) {
134
+ // Up to 0.488.0 a full list also marked entries outside its window, which no later list returns to unmark.
135
+ await store.restoreUnseen(calendar.id, calendarSyncWindow, now);
136
+ cursor = { ...cursor, restored: true };
137
+ }
138
+ let round = roundFor(source, stored, { window, now });
102
139
  if (round !== stored?.round)
103
140
  cursor = { ...cursor, done: 0 };
104
141
  const show = () => Object.assign(result, { round: round.full ? "full" : "changes", reset: round.reset });
@@ -107,12 +144,26 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
107
144
  cursor = { ...cursor, round, updatedAt: now, ...finished };
108
145
  await store.setCursor(calendar.id, JSON.stringify(cursor), now);
109
146
  };
110
- const saveSlots = async (slots) => {
111
- if (!slots.length)
112
- return;
113
- await store.saveEntries(calendar.id, slots, now);
114
- result.occurrences += slots.length;
115
- cursor = { ...cursor, done: cursor.done + slots.length };
147
+ /**
148
+ * Series' slots and the cursor in one transaction. A series' slots replace its stored ones in the window: a slot it
149
+ * no longer has, moved or dropped, is cancelled; one kept as stored counts as seen by this round.
150
+ */
151
+ const commit = async (groups) => {
152
+ const count = groups.reduce((sum, { slots }) => sum + (slots?.length ?? 0), 0);
153
+ cursor = { ...cursor, round, updatedAt: now, done: cursor.done + count };
154
+ const { saved, removed } = await store.saveSlots(calendar.id, groups, round.window, JSON.stringify(cursor), now);
155
+ result.occurrences += saved;
156
+ result.removed += removed;
157
+ };
158
+ /** A series this full round need not ask: the same version as stored, with its slots in the window stored. */
159
+ const unchanged = async (entry) => {
160
+ if (!round.full || round.everySeries !== false || !entry.etag)
161
+ return false;
162
+ const held = await store.entry(calendar.id, entry.externalId);
163
+ return (held !== null &&
164
+ held.etag === entry.etag &&
165
+ held.deletedAt === null &&
166
+ (await store.slotsOf(calendar.id, entry.externalId, round.window)).length > 0);
116
167
  };
117
168
  const stop = () => budget.take();
118
169
  let restarted = false;
@@ -130,7 +181,7 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
130
181
  // A page token held since an earlier run may have expired; the provider says how long none of them last.
131
182
  if (round.page !== null && !restarted) {
132
183
  restarted = true;
133
- round = freshRound(round.full, round.from, round.window, now, round.reset);
184
+ round = freshRound(round.full, round.from, round.window, now, round.reset, round.everySeries !== false);
134
185
  }
135
186
  else if (round.full)
136
187
  throw deadList(source.provider);
@@ -145,15 +196,22 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
145
196
  }
146
197
  if (signal?.aborted)
147
198
  return "cancelled";
199
+ const groups = [];
200
+ const listed = page.entries.filter((entry) => entry.kind === "series" && entry.status !== "cancelled");
201
+ const kept = new Set();
202
+ if (source.traits.expands === "on-request")
203
+ for (const entry of listed)
204
+ if (await unchanged(entry))
205
+ kept.add(entry.externalId);
148
206
  if (page.entries.length) {
149
207
  await store.saveEntries(calendar.id, page.entries, now);
150
208
  result.saved += page.entries.length;
151
209
  cursor = { ...cursor, done: cursor.done + page.entries.length };
152
- for (const entry of page.entries) {
153
- if (entry.kind !== "series" || entry.status === "cancelled")
154
- continue;
210
+ for (const entry of listed) {
155
211
  if (source.traits.expands === "local")
156
- await saveSlots(await expand(entry, round.window));
212
+ groups.push({ series: entry.externalId, slots: await expand(entry, round.window) });
213
+ else if (kept.has(entry.externalId))
214
+ groups.push({ series: entry.externalId, slots: null });
157
215
  else if (source.traits.expands === "on-request" && !round.series.includes(entry.externalId))
158
216
  round = { ...round, series: [...round.series, entry.externalId] };
159
217
  }
@@ -168,21 +226,37 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
168
226
  seen.add(page.nextPage);
169
227
  round = { ...round, page: page.nextPage };
170
228
  }
171
- await save();
229
+ await commit(groups);
172
230
  moved();
173
231
  }
174
232
  const occurrences = source.occurrences;
175
- for (const series of round.series) {
176
- const why = stop();
177
- if (why)
178
- return why;
179
- const slots = await occurrences.call(source, calendar, series, round.window);
180
- if (signal?.aborted)
181
- return "cancelled";
182
- await saveSlots(slots);
183
- round = { ...round, series: round.series.filter((one) => one !== series) };
184
- await save();
185
- moved();
233
+ let batch = [];
234
+ // Whatever stops the loop — the budget, a cancel, a failed request — the series asked so far are kept.
235
+ const flush = async () => {
236
+ if (!batch.length)
237
+ return;
238
+ const done = new Set(batch.map(({ series }) => series));
239
+ round = { ...round, series: round.series.filter((one) => !done.has(one)) };
240
+ const groups = batch;
241
+ batch = [];
242
+ await commit(groups);
243
+ };
244
+ try {
245
+ for (const series of [...round.series]) {
246
+ const why = stop();
247
+ if (why)
248
+ return why;
249
+ const slots = await occurrences.call(source, calendar, series, round.window);
250
+ if (signal?.aborted)
251
+ return "cancelled";
252
+ batch.push({ series, slots });
253
+ moved();
254
+ if (batch.length >= BATCH_SERIES || batch.reduce((sum, one) => sum + (one.slots?.length ?? 0), 0) >= BATCH_SLOTS)
255
+ await flush();
256
+ }
257
+ }
258
+ finally {
259
+ await flush();
186
260
  }
187
261
  if (signal?.aborted)
188
262
  return "cancelled";
@@ -192,6 +266,7 @@ const syncOne = async ({ source, store, calendar, window, now, expand, budget, s
192
266
  window: round.window,
193
267
  fullAt: round.full ? round.startedAt : cursor.fullAt,
194
268
  round: null,
269
+ ...(round.full && round.everySeries !== false ? { slotsTo: round.window.to } : {}),
195
270
  });
196
271
  result.state = "complete";
197
272
  return null;
@@ -257,9 +332,16 @@ export const syncCalendars = async (source, store, options) => {
257
332
  const [x, y] = [rank(a), rank(b)];
258
333
  return x[0] - y[0] || x[1] - y[1];
259
334
  });
335
+ if (options.full)
336
+ // Every calendar keeps the rescan as its round in progress, so one this run does not reach starts it next time.
337
+ for (const one of synced) {
338
+ const round = freshRound(true, null, window, now);
339
+ const fresh = { v: 2, token: null, window, fullAt: 0, updatedAt: null, restored: true };
340
+ one.stored = { ...(one.stored ?? fresh), done: 0, round };
341
+ await store.setCursor(one.calendar.id, JSON.stringify(one.stored), now);
342
+ }
260
343
  for (const { calendar, stored } of synced) {
261
- const asked = options.full === true;
262
- const planned = roundFor(source, stored, { asked, window, now });
344
+ const planned = roundFor(source, stored, { window, now });
263
345
  const result = {
264
346
  calendarId: calendar.id,
265
347
  externalId: calendar.externalId,
@@ -288,7 +370,7 @@ export const syncCalendars = async (source, store, options) => {
288
370
  moved: () => {
289
371
  steps += 1;
290
372
  },
291
- }, stored, asked, result);
373
+ }, stored, result);
292
374
  }
293
375
  catch (error) {
294
376
  if (options.signal?.aborted) {
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { calendarText, joinKeyOf, joinUrlWithoutQuery } from "../../meetings/index.js";
2
+ import { calendarText, entryInWindow, eventChange, joinKeyOf, joinUrlWithoutQuery, } from "../../meetings/index.js";
3
3
  const TRACKED = ["title", "startsAt", "endsAt", "location", "status", "recurrence", "recurrenceEndsAt"];
4
4
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
5
5
  /**
@@ -77,6 +77,7 @@ export const memoryEventStore = () => {
77
77
  id: nextId++,
78
78
  linkableType: "calendar_entry",
79
79
  linkableId: entry.id,
80
+ calendarId: entry.calendarId,
80
81
  entityType: entity,
81
82
  candidates: ids,
82
83
  reason: "ical-uid",
@@ -229,6 +230,7 @@ export const memoryEventStore = () => {
229
230
  recurrence: input.recurrence ?? null,
230
231
  recurrenceEndsAt: input.recurrenceEndsAt ?? null,
231
232
  eventType: input.eventType ?? null,
233
+ transparency: input.transparency ?? null,
232
234
  ownResponse: input.ownResponse ?? null,
233
235
  organizerIdentityId: input.organizer ? contactOf(input.organizer.email) : null,
234
236
  joinUrl,
@@ -248,7 +250,7 @@ export const memoryEventStore = () => {
248
250
  Object.assign(found, values);
249
251
  }
250
252
  else {
251
- entry = { id: nextId++, calendarId, eventId: null, eventSeriesId: null, ...values };
253
+ entry = { id: nextId++, calendarId, eventId: null, eventSeriesId: null, createdAt: now, ...values };
252
254
  entries.push(entry);
253
255
  }
254
256
  const id = entry.id;
@@ -302,7 +304,7 @@ export const memoryEventStore = () => {
302
304
  throw new CliError("not_found", `no calendar entry ${id} in the local store`);
303
305
  return entry;
304
306
  };
305
- return {
307
+ const store = {
306
308
  async saveCalendars(accountId, list, now) {
307
309
  return list.map((one) => {
308
310
  let calendar = calendars.find((held) => held.accountId === accountId && held.externalId === one.externalId);
@@ -377,9 +379,7 @@ export const memoryEventStore = () => {
377
379
  const marked = entries.filter((one) => one.calendarId === calendarId &&
378
380
  one.deletedAt === null &&
379
381
  (one.seenAt === null || one.seenAt < seenBefore) &&
380
- (one.kind !== "occurrence" ||
381
- one.exception ||
382
- (one.originalStartsAt !== null && one.originalStartsAt >= window.from && one.originalStartsAt < window.to)));
382
+ entryInWindow(one, window));
383
383
  for (const one of marked)
384
384
  Object.assign(one, { deletedAt: now, updatedAt: now });
385
385
  for (const eventId of new Set(marked.map(({ eventId }) => eventId)))
@@ -387,10 +387,56 @@ export const memoryEventStore = () => {
387
387
  refreshEvent(eventId, now);
388
388
  return marked.length;
389
389
  },
390
+ async restoreUnseen(calendarId, windowAt, now) {
391
+ const restored = entries.filter((one) => one.calendarId === calendarId && one.deletedAt !== null && !entryInWindow(one, windowAt(one.deletedAt)));
392
+ for (const one of restored)
393
+ Object.assign(one, { deletedAt: null, updatedAt: now });
394
+ refreshAround(restored.map(({ id }) => id), now);
395
+ return restored.length;
396
+ },
390
397
  async entry(calendarId, externalId) {
391
398
  const found = entries.find((one) => one.calendarId === calendarId && one.externalId === externalId);
392
399
  return found ? stored(found) : null;
393
400
  },
401
+ async slotsOf(calendarId, seriesExternalId, window) {
402
+ return entries
403
+ .filter((one) => one.calendarId === calendarId &&
404
+ one.kind === "occurrence" &&
405
+ one.seriesExternalId === seriesExternalId &&
406
+ one.originalStartsAt !== null &&
407
+ one.originalStartsAt >= window.from &&
408
+ one.originalStartsAt < window.to &&
409
+ one.deletedAt === null)
410
+ .sort((a, b) => a.originalStartsAt - b.originalStartsAt)
411
+ .map(stored);
412
+ },
413
+ async saveSlots(calendarId, groups, window, cursor, now) {
414
+ let [saved, removed] = [0, 0];
415
+ for (const { series, slots } of groups) {
416
+ const held = entries.filter((one) => one.calendarId === calendarId &&
417
+ one.kind === "occurrence" &&
418
+ one.seriesExternalId === series &&
419
+ one.originalStartsAt !== null &&
420
+ one.originalStartsAt >= window.from &&
421
+ one.originalStartsAt < window.to &&
422
+ one.deletedAt === null);
423
+ if (slots === null) {
424
+ for (const one of held)
425
+ one.seenAt = now;
426
+ continue;
427
+ }
428
+ if (slots.length)
429
+ saved += (await store.saveEntries(calendarId, slots, now)).length;
430
+ const given = new Set(slots.map(({ externalId }) => externalId));
431
+ const stale = held
432
+ .filter((one) => !one.exception && one.status !== "cancelled" && !given.has(one.externalId))
433
+ .map(({ externalId }) => externalId);
434
+ if (stale.length)
435
+ removed += await store.removeEntries(calendarId, stale, now);
436
+ }
437
+ await store.setCursor(calendarId, cursor, now);
438
+ return { saved, removed };
439
+ },
394
440
  async cursor(calendarId) {
395
441
  return cursors.get(calendarId) ?? null;
396
442
  },
@@ -407,6 +453,17 @@ export const memoryEventStore = () => {
407
453
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
408
454
  throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
409
455
  const has = (event, check) => entries.some((one) => one.eventId === event.id && check(one));
456
+ const copiesOf = (event) => entries.filter((one) => one.eventId === event.id &&
457
+ (filter.accountId === undefined || calendarOf(one.calendarId).accountId === filter.accountId));
458
+ const busy = (event) => event.deletedAt === null &&
459
+ event.status !== "cancelled" &&
460
+ !event.allDay &&
461
+ event.startsAt !== null &&
462
+ event.endsAt !== null &&
463
+ copiesOf(event).some((one) => one.deletedAt === null &&
464
+ one.status !== "cancelled" &&
465
+ one.ownResponse !== "declined" &&
466
+ one.transparency !== "transparent");
410
467
  return [...events.values()]
411
468
  .filter((event) => event.deletedAt === null &&
412
469
  (filter.from === undefined ||
@@ -421,11 +478,36 @@ export const memoryEventStore = () => {
421
478
  (filter.accountId === undefined ||
422
479
  has(event, (one) => calendarOf(one.calendarId).accountId === filter.accountId)) &&
423
480
  (filter.personId === undefined ||
424
- has(event, (one) => participants.some((p) => p.entryId === one.id && p.identityId === filter.personId))))
481
+ has(event, (one) => participants.some((p) => p.entryId === one.id && p.identityId === filter.personId))) &&
482
+ (filter.changedSince === undefined ||
483
+ eventChange(event, copiesOf(event), revisions, filter.changedSince) !== null) &&
484
+ (!filter.overlapping ||
485
+ (busy(event) &&
486
+ [...events.values()].some((other) => other.id !== event.id &&
487
+ busy(other) &&
488
+ other.startsAt < event.endsAt &&
489
+ other.endsAt > event.startsAt))))
425
490
  .sort((a, b) => ((a.startsAt ?? -1) - (b.startsAt ?? -1) || a.id - b.id) * (filter.newest ? -1 : 1))
426
491
  .slice(offset, offset + limit)
427
492
  .map((event) => ({ ...event }));
428
493
  },
494
+ async historyOf(eventIds, calendarIds, since) {
495
+ const copies = entries.filter((one) => one.eventId !== null && eventIds.includes(one.eventId) && calendarIds.includes(one.calendarId));
496
+ const ids = new Set(copies.map(({ id }) => id));
497
+ return {
498
+ entries: copies.map(stored),
499
+ revisions: revisions.filter((one) => ids.has(one.entryId) && one.createdAt >= since).map((one) => ({ ...one })),
500
+ };
501
+ },
502
+ async participantsOf(eventIds, calendarIds) {
503
+ const wanted = new Map(entries
504
+ .filter((one) => one.eventId !== null && eventIds.includes(one.eventId) && calendarIds.includes(one.calendarId))
505
+ .map((one) => [one.id, one.eventId]));
506
+ return participants
507
+ .filter((one) => wanted.has(one.entryId))
508
+ .map((one) => ({ ...one, eventId: wanted.get(one.entryId) }))
509
+ .sort((a, b) => a.eventId - b.eventId || a.entryId - b.entryId || a.email.localeCompare(b.email));
510
+ },
429
511
  async event(id) {
430
512
  const event = events.get(id);
431
513
  if (!event)
@@ -486,4 +568,5 @@ export const memoryEventStore = () => {
486
568
  return { ...review, candidates: [...review.candidates] };
487
569
  },
488
570
  };
571
+ return store;
489
572
  };
@@ -161,6 +161,73 @@ export const calendarSyncContract = (make) => CALENDAR_TRAIT_COMBINATIONS.flatMa
161
161
  const event = await store.event(entry?.eventId);
162
162
  assert.equal(event?.event.status, "cancelled");
163
163
  }),
164
+ test("a series moved to another time keeps no slot at the old one", async ({ store, source, sync }) => {
165
+ if (traits.expands === "in-changes")
166
+ return;
167
+ putSeries(source, seriesOf("s"), [slot("s", AT), slot("s", AT + DAY), slot("s", AT + 2 * DAY)]);
168
+ await sync(NOW);
169
+ const later = AT + 2 * HOUR;
170
+ putSeries(source, { ...seriesOf("s"), startsAt: later, endsAt: later + HOUR }, [
171
+ slot("s", later),
172
+ slot("s", later + DAY),
173
+ slot("s", later + 2 * DAY),
174
+ ]);
175
+ await sync(NOW + HOUR);
176
+ const entries = await entriesOf(store);
177
+ assert.deepEqual([AT, AT + DAY, AT + 2 * DAY].map((at) => entries.get(slotId("s", at))?.status), ["cancelled", "cancelled", "cancelled"]);
178
+ assert.deepEqual([later, later + DAY, later + 2 * DAY].map((at) => entries.get(slotId("s", at))?.status), ["confirmed", "confirmed", "confirmed"]);
179
+ }),
180
+ test("a daily full round asks only the series that changed, and every series once a week", async ({ store, source, sync, }) => {
181
+ if (traits.expands !== "on-request" || traits.fullListOnly)
182
+ return;
183
+ const tagged = (series, etag) => ({ ...series, etag });
184
+ const days = (from, count) => Array.from({ length: count }, (_, n) => slot("s", from + n * DAY));
185
+ putSeries(source, tagged(seriesOf("s"), "v1"), days(AT, 3));
186
+ putSeries(source, tagged(seriesOf("t"), "v1"), [slot("t", AT)]);
187
+ await sync(NOW);
188
+ const asked = () => source.calls.occurrences.length;
189
+ const before = asked();
190
+ putSeries(source, tagged(seriesOf("t"), "v2"), [slot("t", AT), slot("t", AT + DAY)]);
191
+ const nextDay = await sync(NOW + DAY);
192
+ assert.equal(nextDay.synced[0]?.round, "full");
193
+ assert.deepEqual(source.calls.occurrences.slice(before), ["t"]);
194
+ const entries = await entriesOf(store);
195
+ assert.deepEqual(days(AT, 3).map(({ externalId }) => [entries.get(externalId)?.status, entries.get(externalId)?.deletedAt]), [
196
+ ["confirmed", null],
197
+ ["confirmed", null],
198
+ ["confirmed", null],
199
+ ]);
200
+ const far = AT + 92 * DAY;
201
+ source.setOccurrences("s", [...days(AT, 3), slot("s", far)]);
202
+ const atWeek = before + 1;
203
+ await sync(NOW + 2 * DAY);
204
+ assert.equal(asked(), atWeek);
205
+ await sync(NOW + 8 * DAY);
206
+ assert.ok(source.calls.occurrences.slice(atWeek).includes("s"));
207
+ assert.equal((await entriesOf(store)).get(slotId("s", far))?.status, "confirmed");
208
+ }),
209
+ test("the slots of many series take a few store writes, not two for each series", async ({ store, source, accountId, }) => {
210
+ if (traits.expands !== "on-request")
211
+ return;
212
+ for (let n = 0; n < 120; n++)
213
+ putSeries(source, seriesOf(`s${n}`), [slot(`s${n}`, AT), slot(`s${n}`, AT + DAY)]);
214
+ let writes = 0;
215
+ const counted = new Proxy(store, {
216
+ get: (target, key) => {
217
+ const value = Reflect.get(target, key);
218
+ if (typeof value !== "function")
219
+ return value;
220
+ return (...args) => {
221
+ if (["saveEntries", "removeEntries", "saveSlots", "setCursor"].includes(String(key)))
222
+ writes += 1;
223
+ return value.apply(target, args);
224
+ };
225
+ },
226
+ });
227
+ const report = await syncCalendars(source, counted, { accountId, now: NOW });
228
+ assert.equal(report.synced[0]?.occurrences, 240);
229
+ assert.ok(writes <= 10, `${writes} writes`);
230
+ }),
164
231
  test("a failed page keeps the pages before it, and the next run goes on from the failed one", async ({ store, source, sync }) => {
165
232
  source.put("primary", single("e1"));
166
233
  const first = await sync(NOW);
@@ -224,6 +291,29 @@ export const calendarSyncContract = (make) => CALENDAR_TRAIT_COMBINATIONS.flatMa
224
291
  assert.equal(entries.get("e1")?.deletedAt, null);
225
292
  assert.notEqual(entries.get("e0")?.deletedAt ?? null, null);
226
293
  }, { pageSize: 1 }),
294
+ test("a rescan the budget stops still lists every calendar it did not reach in full on the next run", async ({ source, sync }) => {
295
+ source.put("primary", single("e1"));
296
+ source.put("team", single("t1"));
297
+ await sync(NOW);
298
+ const stopped = await sync(NOW + HOUR, { full: true, budget: runBudget({ requests: 2 }) });
299
+ const rounds = (report) => Object.fromEntries(report.synced.map(({ externalId, round, state }) => [externalId, `${round} ${state}`]));
300
+ assert.deepEqual(rounds(stopped), { primary: "full complete", team: "full partial" });
301
+ assert.equal((await sync(NOW + 2 * HOUR)).synced.find(({ externalId }) => externalId === "team")?.round, "full");
302
+ if (!traits.fullListOnly)
303
+ assert.deepEqual(rounds(await sync(NOW + 3 * HOUR)), {
304
+ primary: "changes complete",
305
+ team: "changes complete",
306
+ });
307
+ }, {
308
+ calendars: ["primary", "team"].map((externalId) => ({
309
+ externalId,
310
+ title: externalId,
311
+ timezone: "UTC",
312
+ accessRole: "owner",
313
+ primary: externalId === "primary",
314
+ syncByDefault: true,
315
+ })),
316
+ }),
227
317
  test("a deadline close by stops the run before its next request", async ({ source, sync }) => {
228
318
  source.put("primary", single("e1"), single("e2"));
229
319
  const endsAt = NOW + 30_000;
@@ -14,11 +14,7 @@ export interface CalendarWriterFixture {
14
14
  notified: () => boolean[];
15
15
  /** A time the provider accepts for a new event; the contract creates one-hour events from it. */
16
16
  at: number;
17
+ /** A second calendar of the same account, for a writer with `move`; its cases are skipped without one. */
18
+ target?: StoredCalendar;
17
19
  }
18
- /**
19
- * What every calendar writer keeps (gcal-cli plan §9, W1): a stale `etag` is a conflict, never an overwrite; a create
20
- * retried with its id finds the event; a repeat rule is kept as given and changed on the series; a timed event
21
- * switches to all-day and back; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
22
- * default never reaches the provider with a write that would.
23
- */
24
20
  export declare const calendarWriterContract: (fixture: () => CalendarWriterFixture | Promise<CalendarWriterFixture>) => ContractCase[];