@wirecat/cli-core 0.463.0 → 0.465.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 (69) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/background/after-upgrade.d.ts +51 -0
  3. package/dist/background/after-upgrade.js +245 -0
  4. package/dist/background/budget.d.ts +37 -0
  5. package/dist/background/budget.js +48 -0
  6. package/dist/background/index.d.ts +1 -0
  7. package/dist/background/index.js +1 -0
  8. package/dist/background/relay.js +5 -1
  9. package/dist/calendar/busy.d.ts +68 -0
  10. package/dist/calendar/busy.js +148 -0
  11. package/dist/calendar/cli/index.d.ts +7 -0
  12. package/dist/calendar/cli/index.js +98 -15
  13. package/dist/calendar/index.d.ts +3 -2
  14. package/dist/calendar/index.js +2 -1
  15. package/dist/calendar/mcp/index.d.ts +55 -13
  16. package/dist/calendar/mcp/index.js +19 -4
  17. package/dist/calendar/recurrence.d.ts +10 -0
  18. package/dist/calendar/recurrence.js +65 -0
  19. package/dist/calendar/source.d.ts +19 -0
  20. package/dist/calendar/sync.d.ts +86 -7
  21. package/dist/calendar/sync.js +271 -105
  22. package/dist/calendar/testing/source-contract.js +14 -0
  23. package/dist/calendar/testing/source.d.ts +6 -0
  24. package/dist/calendar/testing/source.js +30 -2
  25. package/dist/calendar/testing/sync-contract.d.ts +2 -1
  26. package/dist/calendar/testing/sync-contract.js +80 -5
  27. package/dist/calendar/testing/writer-contract.d.ts +2 -1
  28. package/dist/calendar/testing/writer-contract.js +46 -2
  29. package/dist/calendar/testing/writer.js +1 -1
  30. package/dist/calendar/writer.d.ts +7 -2
  31. package/dist/calendar/writes.d.ts +12 -2
  32. package/dist/calendar/writes.js +32 -6
  33. package/dist/cli/context.d.ts +8 -0
  34. package/dist/cli/context.js +15 -0
  35. package/dist/cli/execution.d.ts +7 -0
  36. package/dist/cli/execution.js +7 -0
  37. package/dist/cli/index.d.ts +1 -1
  38. package/dist/cli/index.js +1 -1
  39. package/dist/cli/mcp-server-command.js +16 -10
  40. package/dist/cli/meeting-preparation-answer.d.ts +1 -0
  41. package/dist/cli/meeting-preparation-projects.d.ts +1 -0
  42. package/dist/cli/meeting-preparation-read.d.ts +1 -0
  43. package/dist/cli/messenger/chat-answers.js +1 -0
  44. package/dist/cli/messenger/search-answers.js +29 -4
  45. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  46. package/dist/cli/program.js +15 -5
  47. package/dist/cli/upgrade-command.d.ts +7 -2
  48. package/dist/cli/upgrade-command.js +65 -9
  49. package/dist/entities/fields.d.ts +2 -0
  50. package/dist/entities/fields.js +2 -0
  51. package/dist/entities/hit.d.ts +1 -1
  52. package/dist/entities/hit.js +3 -3
  53. package/dist/entities/index.d.ts +10 -800
  54. package/dist/entities/index.js +37 -31
  55. package/dist/entities/meeting-preparation.d.ts +1 -0
  56. package/dist/entities/topic.d.ts +1 -0
  57. package/dist/entities/topic.js +2 -1
  58. package/dist/errors/reasons.js +4 -0
  59. package/dist/mcp/http/state.d.ts +14 -0
  60. package/dist/mcp/http/state.js +16 -0
  61. package/dist/mcp/meeting-preparation.d.ts +1 -0
  62. package/dist/meetings/answers.d.ts +2 -0
  63. package/dist/meetings/answers.js +3 -0
  64. package/dist/meetings/mcp/search.d.ts +1 -0
  65. package/dist/meetings/project-evidence-answer.d.ts +1 -0
  66. package/dist/ports/app.d.ts +5 -0
  67. package/dist/services/package-upgrade.d.ts +9 -0
  68. package/dist/services/package-upgrade.js +8 -1
  69. package/package.json +3 -3
@@ -1,18 +1,47 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { runBudget } from "../background/budget.js";
3
+ import { progressLine, progressOf } from "../background/checkpoints.js";
2
4
  import { isCliFailure } from "../errors/failures.js";
3
5
  const DAY = 86_400_000;
6
+ const isWindow = (value) => typeof value?.from === "number" && typeof value.to === "number";
7
+ const isToken = (value) => value === null || typeof value === "string";
8
+ const roundOf = (value) => {
9
+ if (value === null || value === undefined)
10
+ return null;
11
+ const round = value;
12
+ if (typeof round.full !== "boolean" ||
13
+ !isToken(round.from) ||
14
+ !isWindow(round.window) ||
15
+ typeof round.startedAt !== "number" ||
16
+ typeof round.reset !== "boolean" ||
17
+ !isToken(round.page) ||
18
+ typeof round.listed !== "boolean" ||
19
+ !isToken(round.next) ||
20
+ !Array.isArray(round.series) ||
21
+ round.series.some((id) => typeof id !== "string"))
22
+ 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 };
25
+ };
4
26
  export const parseCalendarCursor = (value) => {
5
27
  if (value === null)
6
28
  return null;
7
29
  try {
8
- const parsed = JSON.parse(value);
9
- if (parsed?.v !== 1 ||
10
- (parsed.token !== null && typeof parsed.token !== "string") ||
11
- typeof parsed.fullAt !== "number" ||
12
- typeof parsed.window?.from !== "number" ||
13
- typeof parsed.window.to !== "number")
30
+ const cursor = JSON.parse(value);
31
+ if ((cursor?.v !== 1 && cursor?.v !== 2) ||
32
+ !isToken(cursor.token) ||
33
+ typeof cursor.fullAt !== "number" ||
34
+ !isWindow(cursor.window))
35
+ return null;
36
+ const { token, window, fullAt } = cursor;
37
+ if (cursor.v === 1)
38
+ return { v: 2, token, window, fullAt, updatedAt: null, done: 0, round: null };
39
+ const round = roundOf(cursor.round);
40
+ if (round === undefined ||
41
+ (cursor.updatedAt !== null && typeof cursor.updatedAt !== "number") ||
42
+ typeof cursor.done !== "number")
14
43
  return null;
15
- return { v: 1, token: parsed.token, window: parsed.window, fullAt: parsed.fullAt };
44
+ return { v: 2, token, window, fullAt, updatedAt: cursor.updatedAt ?? null, done: cursor.done, round };
16
45
  }
17
46
  catch {
18
47
  return null;
@@ -27,141 +56,278 @@ export const calendarSyncWindow = (now) => {
27
56
  const [year, month, date] = [day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()];
28
57
  return { from: Date.UTC(year - 1, month, date), to: Date.UTC(year, month + 3, date) };
29
58
  };
30
- const roundOf = async (source, store, calendar, cursor, window, now) => {
31
- const round = { token: null, saved: 0, removed: 0, series: [] };
32
- const seen = new Set();
33
- let page;
34
- for (;;) {
35
- const result = await source.changes(calendar, { cursor, window, ...(page === undefined ? {} : { page }) });
36
- if ("reset" in result)
37
- return "reset";
38
- if (result.entries.length) {
39
- await store.saveEntries(calendar.id, result.entries, now);
40
- round.saved += result.entries.length;
41
- for (const entry of result.entries)
42
- if (entry.kind === "series")
43
- round.series.push(entry);
44
- }
45
- if (result.removed.length)
46
- round.removed += await store.removeEntries(calendar.id, result.removed, now);
47
- if (result.nextPage === undefined) {
48
- round.token = result.cursor ?? null;
49
- return round;
50
- }
51
- if (seen.has(result.nextPage))
52
- throw new CliError("invalid_response", `${source.provider} returned the same page twice`);
53
- seen.add(result.nextPage);
54
- page = result.nextPage;
55
- }
56
- };
57
- const occurrencesOf = async (source, store, calendar, series, window, now, expand) => {
58
- if (source.traits.expands === "in-changes")
59
- return 0;
60
- let saved = 0;
61
- for (const one of series) {
62
- if (one.status === "cancelled")
63
- continue;
64
- const slots = source.traits.expands === "local"
65
- ? await expand(one, window)
66
- : await source.occurrences(calendar, one.externalId, window);
67
- if (!slots.length)
68
- continue;
69
- await store.saveEntries(calendar.id, slots, now);
70
- saved += slots.length;
71
- }
72
- return saved;
73
- };
74
- const syncOne = async (source, store, calendar, { full: asked, window, now, expand }) => {
75
- const stored = parseCalendarCursor(await store.cursor(calendar.id));
59
+ const CONTINUE = "run sync again to continue";
60
+ const freshRound = (full, from, window, now, reset = false) => ({
61
+ full,
62
+ from: full ? null : from,
63
+ window,
64
+ startedAt: now,
65
+ reset,
66
+ page: null,
67
+ listed: false,
68
+ next: null,
69
+ series: [],
70
+ });
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)
74
+ return stored.round;
76
75
  const windowMoved = stored !== null && (stored.window.from !== window.from || stored.window.to !== window.to);
77
76
  // A full round once a day also asks every series for its slots again, which an incremental round does only for
78
77
  // the series that changed (foundation §6.2 step 7).
79
- let full = asked ||
78
+ const full = asked ||
80
79
  source.traits.fullListOnly ||
81
80
  stored === null ||
82
81
  stored.token === null ||
83
82
  (source.traits.cursorHoldsWindow && windowMoved) ||
84
83
  now - stored.fullAt >= DAY;
85
- let reset = false;
86
- let round = await roundOf(source, store, calendar, full ? null : (stored?.token ?? null), window, now);
87
- if (round === "reset") {
88
- if (full)
89
- throw new CliError("invalid_response", `${source.provider} refused a full list as a dead cursor`);
90
- reset = true;
91
- full = true;
92
- await store.setCursor(calendar.id, null, now);
93
- round = await roundOf(source, store, calendar, null, window, now);
94
- if (round === "reset")
95
- throw new CliError("invalid_response", `${source.provider} refused a full list as a dead cursor`);
96
- }
97
- const occurrences = await occurrencesOf(source, store, calendar, round.series, window, now, expand);
98
- const unseen = full ? await store.markUnseen(calendar.id, now, window, now) : 0;
99
- const cursor = { v: 1, token: round.token, window, fullAt: full ? now : (stored?.fullAt ?? now) };
100
- await store.setCursor(calendar.id, JSON.stringify(cursor), now);
101
- return {
102
- calendarId: calendar.id,
103
- externalId: calendar.externalId,
104
- title: calendar.title,
105
- round: full ? "full" : "changes",
106
- reset,
107
- saved: round.saved,
108
- removed: round.removed,
109
- occurrences,
110
- unseen,
84
+ return freshRound(full, stored?.token ?? null, window, now);
85
+ };
86
+ const deadList = (provider) => new CliError("invalid_response", `${provider} refused a full list as a dead cursor`);
87
+ /**
88
+ * One calendar's round, saved after every page and every series, so a stop — the budget, a cancel, a kill — loses
89
+ * at most the step it was on. Returns why it stopped, or `null` when the round finished.
90
+ */
91
+ const syncOne = async ({ source, store, calendar, window, now, expand, budget, signal, moved }, stored, asked, result) => {
92
+ let cursor = stored ?? {
93
+ v: 2,
94
+ token: null,
95
+ window,
96
+ fullAt: 0,
97
+ updatedAt: null,
98
+ done: 0,
99
+ round: null,
100
+ };
101
+ let round = roundFor(source, stored, { asked, window, now });
102
+ if (round !== stored?.round)
103
+ cursor = { ...cursor, done: 0 };
104
+ const show = () => Object.assign(result, { round: round.full ? "full" : "changes", reset: round.reset });
105
+ show();
106
+ const save = async (finished) => {
107
+ cursor = { ...cursor, round, updatedAt: now, ...finished };
108
+ await store.setCursor(calendar.id, JSON.stringify(cursor), now);
111
109
  };
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 };
116
+ };
117
+ const stop = () => budget.take();
118
+ let restarted = false;
119
+ const seen = new Set();
120
+ while (!round.listed) {
121
+ const why = stop();
122
+ if (why)
123
+ return why;
124
+ const page = await source.changes(calendar, {
125
+ cursor: round.from,
126
+ window: round.window,
127
+ ...(round.page === null ? {} : { page: round.page }),
128
+ });
129
+ if ("reset" in page) {
130
+ // A page token held since an earlier run may have expired; the provider says how long none of them last.
131
+ if (round.page !== null && !restarted) {
132
+ restarted = true;
133
+ round = freshRound(round.full, round.from, round.window, now, round.reset);
134
+ }
135
+ else if (round.full)
136
+ throw deadList(source.provider);
137
+ else {
138
+ round = freshRound(true, null, window, now, true);
139
+ cursor = { ...cursor, token: null };
140
+ }
141
+ cursor = { ...cursor, done: 0 };
142
+ show();
143
+ await save();
144
+ continue;
145
+ }
146
+ if (signal?.aborted)
147
+ return "cancelled";
148
+ if (page.entries.length) {
149
+ await store.saveEntries(calendar.id, page.entries, now);
150
+ result.saved += page.entries.length;
151
+ 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;
155
+ if (source.traits.expands === "local")
156
+ await saveSlots(await expand(entry, round.window));
157
+ else if (source.traits.expands === "on-request" && !round.series.includes(entry.externalId))
158
+ round = { ...round, series: [...round.series, entry.externalId] };
159
+ }
160
+ }
161
+ if (page.removed.length)
162
+ result.removed += await store.removeEntries(calendar.id, page.removed, now);
163
+ if (page.nextPage === undefined)
164
+ round = { ...round, page: null, listed: true, next: page.cursor ?? null };
165
+ else {
166
+ if (seen.has(page.nextPage))
167
+ throw new CliError("invalid_response", `${source.provider} returned the same page twice`);
168
+ seen.add(page.nextPage);
169
+ round = { ...round, page: page.nextPage };
170
+ }
171
+ await save();
172
+ moved();
173
+ }
174
+ 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();
186
+ }
187
+ if (signal?.aborted)
188
+ return "cancelled";
189
+ result.unseen = round.full ? await store.markUnseen(calendar.id, round.startedAt, round.window, now) : 0;
190
+ await save({
191
+ token: round.next,
192
+ window: round.window,
193
+ fullAt: round.full ? round.startedAt : cursor.fullAt,
194
+ round: null,
195
+ });
196
+ result.state = "complete";
197
+ return null;
112
198
  };
113
199
  /**
114
200
  * Brings one calendar account's synced calendars up to date (foundation §6.2): the calendar list first, then for
115
201
  * each synced calendar its changes since the cursor, or a full list of the window, then the slots of the series that
116
- * changed. Each calendar's cursor is saved last, only when all of it saved; a calendar that fails keeps its old one
117
- * and the others go on.
202
+ * changed. Every page is saved as it comes, with the round's position in the cursor, so a run the budget stops, or
203
+ * one that fails, goes on from there next time. Calendars with a round in progress go first, then the longest unsynced.
118
204
  */
119
205
  export const syncCalendars = async (source, store, options) => {
120
206
  const now = options.now ?? Date.now();
121
207
  const window = options.window ?? calendarSyncWindow(now);
208
+ const budget = options.budget ?? runBudget();
122
209
  if (source.traits.expands === "local" && !options.expand)
123
210
  throw new CliError("configuration_error", `${source.provider} needs a local expander for its repeating events`);
124
211
  if (source.traits.expands === "on-request" && !source.occurrences)
125
212
  throw new CliError("configuration_error", `${source.provider} expands on request but has no occurrences()`);
126
- const listed = await store.saveCalendars(options.accountId, await source.calendars(), now);
127
213
  const report = {
128
214
  provider: source.provider,
129
215
  accountId: options.accountId,
130
216
  window,
131
- calendars: listed.length,
217
+ calendars: 0,
132
218
  synced: [],
133
219
  notSynced: [],
134
220
  complete: true,
221
+ state: "complete",
222
+ progress: { calendars: 0, calendarsDone: 0, requests: 0, saved: 0, steps: 0 },
135
223
  };
224
+ const finish = (stopped) => {
225
+ report.progress = {
226
+ calendars: report.synced.length,
227
+ calendarsDone: report.synced.filter((one) => one.state === "complete").length,
228
+ requests: budget.used,
229
+ saved: report.synced.reduce((sum, one) => sum + one.saved + one.occurrences, 0),
230
+ steps,
231
+ };
232
+ report.complete = report.synced.every((one) => one.state === "complete" && one.error === undefined);
233
+ report.state = report.complete && stopped === null ? "complete" : "partial";
234
+ if (stopped)
235
+ report.stopped = stopped;
236
+ if (report.state === "partial") {
237
+ report.complete = false;
238
+ report.hint = CONTINUE;
239
+ }
240
+ return report;
241
+ };
242
+ let steps = 0;
243
+ let stopped = budget.take();
244
+ if (stopped)
245
+ return finish(stopped);
246
+ const listed = await store.saveCalendars(options.accountId, await source.calendars(), now);
247
+ report.calendars = listed.length;
248
+ const synced = [];
136
249
  for (const calendar of listed) {
137
- if (!calendar.synced) {
250
+ if (calendar.synced)
251
+ synced.push({ calendar, stored: parseCalendarCursor(await store.cursor(calendar.id)) });
252
+ else
138
253
  report.notSynced.push({ calendarId: calendar.id, externalId: calendar.externalId, title: calendar.title });
254
+ }
255
+ const rank = ({ stored }) => [stored?.round && !options.full ? 0 : 1, stored?.updatedAt ?? 0];
256
+ synced.sort((a, b) => {
257
+ const [x, y] = [rank(a), rank(b)];
258
+ return x[0] - y[0] || x[1] - y[1];
259
+ });
260
+ for (const { calendar, stored } of synced) {
261
+ const asked = options.full === true;
262
+ const planned = roundFor(source, stored, { asked, window, now });
263
+ const result = {
264
+ calendarId: calendar.id,
265
+ externalId: calendar.externalId,
266
+ title: calendar.title,
267
+ round: planned.full ? "full" : "changes",
268
+ reset: planned.reset,
269
+ state: "partial",
270
+ saved: 0,
271
+ removed: 0,
272
+ occurrences: 0,
273
+ unseen: 0,
274
+ };
275
+ report.synced.push(result);
276
+ if (stopped)
139
277
  continue;
140
- }
141
278
  try {
142
- report.synced.push(await syncOne(source, store, calendar, {
143
- full: options.full === true,
279
+ stopped = await syncOne({
280
+ source,
281
+ store,
282
+ calendar,
144
283
  window,
145
284
  now,
146
- ...(options.expand ? { expand: options.expand } : {}),
147
- }));
285
+ expand: options.expand,
286
+ budget,
287
+ signal: options.signal,
288
+ moved: () => {
289
+ steps += 1;
290
+ },
291
+ }, stored, asked, result);
148
292
  }
149
293
  catch (error) {
150
- report.complete = false;
151
- report.synced.push({
152
- calendarId: calendar.id,
153
- externalId: calendar.externalId,
154
- title: calendar.title,
155
- round: options.full ? "full" : "changes",
156
- reset: false,
157
- saved: 0,
158
- removed: 0,
159
- occurrences: 0,
160
- unseen: 0,
161
- // An adapter's own failure says what went wrong; anything else may carry a request URL with a token in it.
162
- error: isCliFailure(error) ? error.message : "the calendar could not be synced",
163
- });
294
+ if (options.signal?.aborted) {
295
+ stopped = "cancelled";
296
+ continue;
297
+ }
298
+ // An adapter's own failure says what went wrong; anything else may carry a request URL with a token in it.
299
+ result.error = isCliFailure(error) ? error.message : "the calendar could not be synced";
164
300
  }
165
301
  }
166
- return report;
302
+ return finish(stopped);
303
+ };
304
+ /**
305
+ * Each synced calendar's sync in the WC-LIFE-14 shape: `done` counts the entries its current or last round saved,
306
+ * `total` is null since no provider says how many a window holds, and `cursor_age_ms` growing on a `partial` one
307
+ * means nothing is moving it.
308
+ */
309
+ export const calendarStatus = async (store, accountId, { profile, now }) => {
310
+ const items = [];
311
+ for (const calendar of await store.calendars(accountId)) {
312
+ if (!calendar.synced || calendar.deletedAt !== null)
313
+ continue;
314
+ const cursor = parseCalendarCursor(await store.cursor(calendar.id));
315
+ const progress = progressOf({
316
+ job: `calendar ${calendar.id}`,
317
+ profile,
318
+ done: cursor?.done ?? 0,
319
+ complete: cursor !== null && cursor.round === null,
320
+ ...(cursor?.updatedAt == null ? {} : { updatedAt: new Date(cursor.updatedAt).toISOString() }),
321
+ now: new Date(now),
322
+ });
323
+ items.push({
324
+ calendar_id: String(calendar.id),
325
+ external_id: calendar.externalId,
326
+ title: calendar.title,
327
+ round: cursor?.round ? (cursor.round.full ? "full" : "changes") : null,
328
+ ...progress,
329
+ line: progressLine(progress),
330
+ });
331
+ }
332
+ return { items, page: 1, limit: Math.max(1, items.length), hasMore: false };
167
333
  };
@@ -167,5 +167,19 @@ export const calendarSourceContract = (make) => {
167
167
  if (fixture.movedOut)
168
168
  assert.ok(!removed.includes(fixture.movedOut), "an entry outside the window is no removal");
169
169
  }),
170
+ test("free/busy answers each calendar and person once, inside the window; an unknown person is an error", async ({ source, window, }) => {
171
+ if (!source.freeBusy)
172
+ return;
173
+ const [first] = await source.calendars();
174
+ const calendar = first.externalId;
175
+ const nobody = "nobody@example.invalid";
176
+ const answer = await source.freeBusy({ calendars: [calendar], people: [nobody], window });
177
+ assert.deepEqual(answer.map(({ id }) => id).sort(), [calendar, nobody].sort());
178
+ for (const { id, busy } of answer)
179
+ for (const one of busy)
180
+ assert.ok(one.from < one.to && one.from >= window.from && one.to <= window.to, `${id}: ${JSON.stringify(one)}`);
181
+ const unknown = answer.find(({ id }) => id === nobody);
182
+ assert.ok(unknown?.error, "a person the provider cannot see is not free");
183
+ }),
170
184
  ];
171
185
  };
@@ -23,8 +23,14 @@ export interface FakeCalendarSource extends CalendarSource {
23
23
  setOccurrences(seriesExternalId: string, entries: EntryInput[]): void;
24
24
  /** Every cursor given out so far is dead: the next round with one gets `{ reset: true }`. */
25
25
  expireCursors(): void;
26
+ /** Every page token given out so far is dead, as one held too long: asking for it gets `{ reset: true }`. */
27
+ expirePages(): void;
26
28
  /** The `changes()` call after this many more succeeds throws once. */
27
29
  failAfter(calls: number): void;
30
+ /** A person's busy times, or the provider's refusal to say; a person never set is `notFound`. */
31
+ setBusy(person: string, busy: SyncWindow[] | {
32
+ error: string;
33
+ }): void;
28
34
  readonly calls: {
29
35
  changes: {
30
36
  calendar: string;
@@ -19,8 +19,10 @@ export const fakeCalendarSource = (options = {}) => {
19
19
  const held = new Map();
20
20
  const log = [];
21
21
  const occurrences = new Map();
22
+ const people = new Map();
22
23
  let version = 0;
23
24
  let deadBefore = 0;
25
+ let pages = 0;
24
26
  let failIn = null;
25
27
  const calls = { changes: [], occurrences: [] };
26
28
  const of = (calendar) => {
@@ -62,13 +64,16 @@ export const fakeCalendarSource = (options = {}) => {
62
64
  }
63
65
  if (cursor === null || traits.fullListOnly)
64
66
  entries = entries.filter((entry) => inside(entry, window));
65
- const from = page === undefined ? 0 : Number(page);
67
+ const [, generation, offset] = /^p(\d+):(\d+)$/u.exec(page ?? `p${pages}:0`) ?? [];
68
+ if (Number(generation) !== pages)
69
+ return { reset: true };
70
+ const from = Number(offset);
66
71
  const slice = entries.slice(from, from + pageSize);
67
72
  const more = from + pageSize < entries.length;
68
73
  return {
69
74
  entries: slice.map((entry) => structuredClone(entry)),
70
75
  removed: from === 0 ? removed : [],
71
- ...(more ? { nextPage: String(from + pageSize) } : { cursor: token(window) }),
76
+ ...(more ? { nextPage: `p${pages}:${from + pageSize}` } : { cursor: token(window) }),
72
77
  };
73
78
  },
74
79
  ...(traits.expands === "on-request"
@@ -81,6 +86,26 @@ export const fakeCalendarSource = (options = {}) => {
81
86
  },
82
87
  }
83
88
  : {}),
89
+ async freeBusy({ calendars: asked, people: persons, window }) {
90
+ const taken = (busy) => busy
91
+ .filter(({ from, to }) => from < window.to && to > window.from)
92
+ .map(({ from, to }) => ({ from: Math.max(from, window.from), to: Math.min(to, window.to) }));
93
+ return [
94
+ ...asked.map((id) => ({
95
+ id,
96
+ busy: taken([...of(id).values()]
97
+ .filter((entry) => entry.status !== "cancelled" && entry.transparency !== "transparent")
98
+ .flatMap(({ startsAt, endsAt }) => startsAt == null || endsAt == null ? [] : [{ from: startsAt, to: endsAt }])),
99
+ })),
100
+ ...persons.map((id) => {
101
+ const busy = people.get(id) ?? { error: "notFound" };
102
+ return Array.isArray(busy) ? { id, busy: taken(busy) } : { id, busy: [], error: busy.error };
103
+ }),
104
+ ];
105
+ },
106
+ setBusy(person, busy) {
107
+ people.set(person, busy);
108
+ },
84
109
  put(calendar, ...entries) {
85
110
  for (const entry of entries) {
86
111
  of(calendar).set(entry.externalId, structuredClone(entry));
@@ -100,6 +125,9 @@ export const fakeCalendarSource = (options = {}) => {
100
125
  setOccurrences(seriesExternalId, entries) {
101
126
  occurrences.set(seriesExternalId, entries);
102
127
  },
128
+ expirePages() {
129
+ pages += 1;
130
+ },
103
131
  expireCursors() {
104
132
  deadBefore = ++version;
105
133
  },
@@ -10,6 +10,7 @@ export declare const CALENDAR_TRAIT_COMBINATIONS: readonly CalendarSourceTraits[
10
10
  /**
11
11
  * The sync engine over `fakeCalendarSource`, once for each trait combination: changes after a cursor, removal by id,
12
12
  * a dead cursor, a window that moved, each way of expanding a series and a slot cancelled later, a failed page, an
13
- * unsynced calendar and a full list. Run it over the store the CLI uses.
13
+ * unsynced calendar, a full list, a run the budget or a near deadline stops and the rerun that finishes it, and a page
14
+ * token that expired between runs. Run it over the store the CLI uses.
14
15
  */
15
16
  export declare const calendarSyncContract: (make: () => CalendarSyncFixture | Promise<CalendarSyncFixture>) => ContractCase[];
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import { runBudget } from "../../background/budget.js";
2
3
  import { parseCalendarCursor, syncCalendars } from "../sync.js";
3
4
  import { fakeCalendarSource } from "./source.js";
4
5
  /** Every combination of the source traits: the engine must handle each. */
@@ -51,7 +52,8 @@ const nameOf = ({ expands, cursorHoldsWindow, fullListOnly }) => `[expands ${exp
51
52
  /**
52
53
  * The sync engine over `fakeCalendarSource`, once for each trait combination: changes after a cursor, removal by id,
53
54
  * a dead cursor, a window that moved, each way of expanding a series and a slot cancelled later, a failed page, an
54
- * unsynced calendar and a full list. Run it over the store the CLI uses.
55
+ * unsynced calendar, a full list, a run the budget or a near deadline stops and the rerun that finishes it, and a page
56
+ * token that expired between runs. Run it over the store the CLI uses.
55
57
  */
56
58
  export const calendarSyncContract = (make) => CALENDAR_TRAIT_COMBINATIONS.flatMap((traits) => {
57
59
  const test = (name, check, options = {}) => ({
@@ -159,21 +161,94 @@ export const calendarSyncContract = (make) => CALENDAR_TRAIT_COMBINATIONS.flatMa
159
161
  const event = await store.event(entry?.eventId);
160
162
  assert.equal(event?.event.status, "cancelled");
161
163
  }),
162
- test("a failed page keeps the old cursor, and the next run reads the same changes again", async ({ store, source, sync }) => {
164
+ test("a failed page keeps the pages before it, and the next run goes on from the failed one", async ({ store, source, sync }) => {
163
165
  source.put("primary", single("e1"));
164
166
  const first = await sync(NOW);
165
167
  const calendarId = first.synced[0]?.calendarId;
166
- const before = await store.cursor(calendarId);
167
168
  source.put("primary", single("e2"), single("e3"));
168
169
  source.failAfter(1);
169
170
  const failed = await sync(NOW + HOUR);
170
- assert.equal(failed.complete, false);
171
+ assert.deepEqual([failed.complete, failed.state], [false, "partial"]);
171
172
  assert.match(failed.synced[0]?.error ?? "", /example provider failed/u);
172
- assert.equal(await store.cursor(calendarId), before);
173
+ const held = (await cursorOf(store, calendarId))?.round;
174
+ assert.ok(held?.page);
173
175
  const again = await sync(NOW + 2 * HOUR);
174
176
  assert.equal(again.complete, true);
177
+ assert.equal(source.calls.changes.at(-2)?.page, held.page);
175
178
  assert.deepEqual([...(await entriesOf(store)).keys()].sort(), ["e1", "e2", "e3"]);
176
179
  }, { pageSize: 1 }),
180
+ test("a run the budget stops goes on next time and ends with the store one whole run gives", async ({ store, source, sync }) => {
181
+ const fill = (one) => {
182
+ one.put("primary", single("e0"));
183
+ return one;
184
+ };
185
+ const grow = (one) => {
186
+ one.drop("primary", "e0");
187
+ one.put("primary", single("e1"), single("e2", AT + HOUR), single("e3", AT + 2 * HOUR));
188
+ const slots = [slot("s", AT), slot("s", AT + DAY), slot("s", AT + 2 * DAY)];
189
+ if (traits.expands === "in-changes")
190
+ one.put("primary", seriesOf("s"), ...slots);
191
+ else
192
+ one.put("primary", seriesOf("s"));
193
+ if (traits.expands === "on-request")
194
+ one.setOccurrences("s", slots);
195
+ };
196
+ const whole = await make();
197
+ const reference = fill(fakeCalendarSource({ pageSize: 1, traits }));
198
+ const expand = traits.expands === "local" ? { expand: exampleExpander } : {};
199
+ const once = (at, extra = {}) => syncCalendars(reference, whole.store, { accountId: whole.accountId, now: at, ...expand, ...extra });
200
+ await once(NOW);
201
+ grow(reference);
202
+ assert.equal((await once(NOW + HOUR, { full: true })).state, "complete");
203
+ fill(source);
204
+ await sync(NOW);
205
+ grow(source);
206
+ const first = await sync(NOW + HOUR, { full: true, budget: runBudget({ requests: 2 }) });
207
+ assert.deepEqual([first.state, first.stopped, first.hint], ["partial", "requests", "run sync again to continue"]);
208
+ assert.equal(first.synced[0]?.state, "partial");
209
+ let runs = 1;
210
+ let last = first;
211
+ while (last.state === "partial" && runs < 30) {
212
+ runs += 1;
213
+ last = await sync(NOW + HOUR + runs * 60_000, { budget: runBudget({ requests: 2 }) });
214
+ if (last.state === "partial")
215
+ assert.equal(last.complete, false);
216
+ }
217
+ assert.equal(last.state, "complete");
218
+ assert.ok(runs > 2);
219
+ const shape = async (one) => [...(await entriesOf(one)).values()]
220
+ .map(({ externalId, status, deletedAt }) => `${externalId} ${status} ${deletedAt === null}`)
221
+ .sort();
222
+ assert.deepEqual(await shape(store), await shape(whole.store));
223
+ const entries = await entriesOf(store);
224
+ assert.equal(entries.get("e1")?.deletedAt, null);
225
+ assert.notEqual(entries.get("e0")?.deletedAt ?? null, null);
226
+ }, { pageSize: 1 }),
227
+ test("a deadline close by stops the run before its next request", async ({ source, sync }) => {
228
+ source.put("primary", single("e1"), single("e2"));
229
+ const endsAt = NOW + 30_000;
230
+ const clock = [NOW, NOW + 1_000, endsAt - 2_000];
231
+ const budget = runBudget({ deadline: { endsAt, ms: 30_000 }, now: () => clock.shift() ?? endsAt });
232
+ const stopped = await sync(NOW, { budget });
233
+ assert.deepEqual([stopped.state, stopped.stopped, budget.used], ["partial", "deadline", 2]);
234
+ assert.equal(stopped.synced[0]?.saved, 1);
235
+ const again = await sync(NOW + HOUR);
236
+ assert.equal(again.state, "complete");
237
+ assert.equal(again.synced[0]?.saved, 1);
238
+ }, { pageSize: 1 }),
239
+ test("a page token that expired since the last run starts that calendar's round over, once", async ({ store, source, sync }) => {
240
+ source.put("primary", single("e1"), single("e2"), single("e3"));
241
+ const first = await sync(NOW, { budget: runBudget({ requests: 2 }) });
242
+ assert.equal(first.state, "partial");
243
+ source.expirePages();
244
+ const again = await sync(NOW + HOUR);
245
+ assert.equal(again.state, "complete");
246
+ const calls = source.calls.changes.slice(1);
247
+ assert.ok(calls[0]?.page);
248
+ assert.equal(calls[1]?.page, undefined);
249
+ assert.deepEqual([...(await entriesOf(store)).keys()].sort(), ["e1", "e2", "e3"]);
250
+ assert.equal((await cursorOf(store, again.synced[0]?.calendarId))?.round, null);
251
+ }, { pageSize: 1 }),
177
252
  test("a calendar the owner does not sync is listed and never read", async ({ source, sync }) => {
178
253
  const report = await sync(NOW);
179
254
  assert.deepEqual(report.notSynced.map(({ externalId }) => externalId), ["team"]);