@wirecat/cli-core 0.455.0 → 0.456.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 (41) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/calendar/cli/index.d.ts +24 -0
  3. package/dist/calendar/cli/index.js +173 -0
  4. package/dist/calendar/index.d.ts +3 -0
  5. package/dist/calendar/index.js +1 -0
  6. package/dist/calendar/mcp/index.d.ts +307 -0
  7. package/dist/calendar/mcp/index.js +132 -0
  8. package/dist/calendar/reads.d.ts +149 -0
  9. package/dist/calendar/reads.js +214 -0
  10. package/dist/calendar/source.d.ts +54 -0
  11. package/dist/calendar/source.js +1 -0
  12. package/dist/calendar/sync.d.ts +65 -0
  13. package/dist/calendar/sync.js +167 -0
  14. package/dist/calendar/testing/index.d.ts +5 -0
  15. package/dist/calendar/testing/index.js +5 -0
  16. package/dist/calendar/testing/memory.d.ts +6 -0
  17. package/dist/calendar/testing/memory.js +478 -0
  18. package/dist/calendar/testing/source-contract.d.ts +31 -0
  19. package/dist/calendar/testing/source-contract.js +171 -0
  20. package/dist/calendar/testing/source.d.ts +38 -0
  21. package/dist/calendar/testing/source.js +110 -0
  22. package/dist/calendar/testing/sync-contract.d.ts +15 -0
  23. package/dist/calendar/testing/sync-contract.js +211 -0
  24. package/dist/errors/reasons.js +1 -0
  25. package/dist/meetings/calendar-text.d.ts +1 -1
  26. package/dist/meetings/calendar-text.js +0 -1
  27. package/dist/meetings/event-store.d.ts +42 -15
  28. package/dist/meetings/index.d.ts +1 -1
  29. package/dist/meetings/testing/event-store-contract.js +83 -4
  30. package/dist/services/index.d.ts +1 -1
  31. package/dist/services/index.js +1 -1
  32. package/dist/services/people.d.ts +6 -1
  33. package/dist/services/people.js +14 -11
  34. package/dist/store/sqlite/calendar.js +68 -31
  35. package/dist/store/sqlite/manifest.js +2 -0
  36. package/dist/store/sqlite/migrations.generated.js +17 -0
  37. package/dist/store/sqlite/note-index.d.ts +1 -1
  38. package/dist/store/sqlite/note-index.js +6 -1
  39. package/dist/store/sqlite/schema.d.ts +16 -0
  40. package/dist/store/sqlite/schema.js +4 -0
  41. package/package.json +17 -1
@@ -0,0 +1,31 @@
1
+ import type { ContractCase } from "../../meetings/index.js";
2
+ import type { CalendarSource, SyncWindow } from "../source.js";
3
+ /**
4
+ * One adapter over recorded responses, fresh for each case. Recorded fixtures answer by request, so a second round
5
+ * with the first round's cursor returns the recorded changes; a source that holds state changes it in `afterFullList`.
6
+ */
7
+ export interface CalendarSourceFixture {
8
+ source: CalendarSource;
9
+ window: SyncWindow;
10
+ /** A cursor the provider refuses; absent for a source with `fullListOnly`. */
11
+ deadCursor?: string;
12
+ /** Called once after the first full list, before the second round. */
13
+ afterFullList?: () => void | Promise<void>;
14
+ /** Every `externalId` the second round holds, entries and removed ids together; absent skips that case. */
15
+ changed?: string[];
16
+ /** An entry the second round moved outside the window: never reported as removed. */
17
+ movedOut?: string;
18
+ /**
19
+ * A slot of a series cancelled at the provider: a cancelled entry or a removed id, or for `expands: "local"` an
20
+ * EXDATE line of its series.
21
+ */
22
+ cancelledSlot?: {
23
+ seriesExternalId: string;
24
+ originalStartsAt: number;
25
+ };
26
+ }
27
+ /**
28
+ * Every adapter runs this over recorded provider responses (foundation §6.3): calendar ids, all-day and repeating
29
+ * entries, neutral words, zones, no meeting secret, RFC 5545 lines, a dead cursor and a second round.
30
+ */
31
+ export declare const calendarSourceContract: (make: () => CalendarSourceFixture | Promise<CalendarSourceFixture>) => ContractCase[];
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+ const STATUS = ["confirmed", "tentative", "cancelled"];
3
+ const RESPONSE = ["needs-action", "accepted", "tentative", "declined", "delegated"];
4
+ const EVENT_TYPE = ["default", "out-of-office", "focus", "working-location", "birthday", "from-mail"];
5
+ const TRANSPARENCY = ["opaque", "transparent"];
6
+ const VISIBILITY = ["default", "public", "private", "confidential"];
7
+ const RECURRENCE_LINE = /^(RRULE|RDATE|EXDATE|EXRULE)[:;]\S/u;
8
+ const SECRET = /\b(?:passcode|password|pin)\s*[:=#]\s*\S|[?&](?:pwd|passcode|pin)=/iu;
9
+ const zoneOk = (zone) => {
10
+ if (zone == null)
11
+ return true;
12
+ try {
13
+ new Intl.DateTimeFormat("en", { timeZone: zone });
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ };
20
+ const storedOf = (calendar) => ({
21
+ id: 1,
22
+ accountId: 1,
23
+ externalId: calendar.externalId,
24
+ title: calendar.title,
25
+ timezone: calendar.timezone,
26
+ accessRole: calendar.accessRole,
27
+ primary: calendar.primary,
28
+ synced: true,
29
+ seenAt: null,
30
+ deletedAt: null,
31
+ });
32
+ const round = async (source, calendar, cursor, window) => {
33
+ const listed = { calendar, entries: [], removed: [], cursor: null };
34
+ let page;
35
+ for (let pages = 0; pages < 1000; pages++) {
36
+ const result = await source.changes(calendar, { cursor, window, ...(page === undefined ? {} : { page }) });
37
+ if ("reset" in result)
38
+ return "reset";
39
+ const { entries, removed, nextPage, cursor: next } = result;
40
+ listed.entries.push(...entries);
41
+ listed.removed.push(...removed);
42
+ if (nextPage === undefined) {
43
+ listed.cursor = next ?? null;
44
+ return listed;
45
+ }
46
+ page = nextPage;
47
+ }
48
+ throw new assert.AssertionError({ message: "the source never stopped paging" });
49
+ };
50
+ const fullList = async (fixture) => {
51
+ const [first] = await fixture.source.calendars();
52
+ assert.ok(first, "the source lists at least one calendar");
53
+ const listed = await round(fixture.source, storedOf(first), null, fixture.window);
54
+ assert.notEqual(listed, "reset", "a full list is never a reset");
55
+ return listed;
56
+ };
57
+ /** The entries and every series' slots, as the engine would see them. */
58
+ const everything = async (fixture, listed) => {
59
+ const slots = [];
60
+ if (fixture.source.traits.expands === "on-request")
61
+ for (const series of listed.entries.filter(({ kind, status }) => kind === "series" && status !== "cancelled"))
62
+ slots.push(...((await fixture.source.occurrences?.(listed.calendar, series.externalId, fixture.window)) ?? []));
63
+ return [...listed.entries, ...slots];
64
+ };
65
+ /**
66
+ * Every adapter runs this over recorded provider responses (foundation §6.3): calendar ids, all-day and repeating
67
+ * entries, neutral words, zones, no meeting secret, RFC 5545 lines, a dead cursor and a second round.
68
+ */
69
+ export const calendarSourceContract = (make) => {
70
+ const test = (name, check) => ({
71
+ name,
72
+ async run() {
73
+ await check(await make());
74
+ },
75
+ });
76
+ return [
77
+ test("calendars have stable, distinct ids", async ({ source }) => {
78
+ const ids = (await source.calendars()).map(({ externalId }) => externalId);
79
+ assert.ok(ids.length > 0 && ids.every((id) => id.trim() !== ""));
80
+ assert.equal(new Set(ids).size, ids.length);
81
+ assert.deepEqual((await source.calendars()).map(({ externalId }) => externalId), ids);
82
+ }),
83
+ test("traits agree with the methods the source has", async ({ source, deadCursor }) => {
84
+ assert.equal(typeof source.occurrences === "function", source.traits.expands === "on-request");
85
+ assert.equal(deadCursor === undefined, source.traits.fullListOnly);
86
+ }),
87
+ test("only neutral words, IANA zones and RFC 5545 lines reach the entries", async (fixture) => {
88
+ for (const entry of await everything(fixture, await fullList(fixture))) {
89
+ const where = `entry ${entry.externalId}`;
90
+ assert.ok(STATUS.includes(entry.status), `${where}: status ${entry.status}`);
91
+ for (const [value, allowed] of [
92
+ [entry.ownResponse, RESPONSE],
93
+ [entry.eventType, EVENT_TYPE],
94
+ [entry.transparency, TRANSPARENCY],
95
+ [entry.visibility, VISIBILITY],
96
+ ...(entry.participants ?? []).map(({ response }) => [response, RESPONSE]),
97
+ ])
98
+ assert.ok(value == null || allowed.includes(value), `${where}: ${value} is not a neutral word`);
99
+ assert.ok(zoneOk(entry.timezone), `${where}: ${entry.timezone} is not an IANA zone`);
100
+ for (const line of entry.recurrence ?? [])
101
+ assert.match(line, RECURRENCE_LINE, `${where}: ${line}`);
102
+ }
103
+ }),
104
+ test("no join link query, passcode or PIN anywhere in the output", async (fixture) => {
105
+ for (const entry of await everything(fixture, await fullList(fixture))) {
106
+ if (entry.joinUrl)
107
+ assert.equal(new URL(entry.joinUrl).search, "", `entry ${entry.externalId}: join link`);
108
+ assert.doesNotMatch(JSON.stringify(entry), SECRET, `entry ${entry.externalId}: a meeting secret`);
109
+ }
110
+ }),
111
+ test("all-day entries carry dates with an exclusive end; slots are UTC milliseconds", async (fixture) => {
112
+ for (const entry of await everything(fixture, await fullList(fixture))) {
113
+ const where = `entry ${entry.externalId}`;
114
+ if (entry.allDay) {
115
+ assert.match(entry.startDate ?? "", /^\d{4}-\d{2}-\d{2}$/u, `${where}: start date`);
116
+ assert.ok((entry.endDate ?? "") > (entry.startDate ?? ""), `${where}: the end date is exclusive`);
117
+ }
118
+ if (entry.startsAt != null && entry.endsAt != null)
119
+ assert.ok(entry.endsAt >= entry.startsAt, where);
120
+ if (entry.kind === "occurrence") {
121
+ assert.ok(Number.isSafeInteger(entry.originalStartsAt), `${where}: original start in milliseconds`);
122
+ if (entry.allDay)
123
+ assert.equal(entry.originalStartsAt % 86_400_000, 0, `${where}: midnight UTC`);
124
+ }
125
+ }
126
+ }),
127
+ test("a series and its occurrences share one iCalendar UID", async (fixture) => {
128
+ const all = await everything(fixture, await fullList(fixture));
129
+ const uids = new Map(all.filter(({ kind }) => kind === "series").map((one) => [one.externalId, one.icalUid]));
130
+ for (const entry of all.filter(({ kind }) => kind === "occurrence")) {
131
+ const uid = entry.seriesExternalId ? uids.get(entry.seriesExternalId) : undefined;
132
+ if (uid && entry.icalUid)
133
+ assert.equal(entry.icalUid, uid, `entry ${entry.externalId}`);
134
+ }
135
+ }),
136
+ test("a cancelled slot arrives as a cancelled entry or a removed id, never silently", async (fixture) => {
137
+ if (!fixture.cancelledSlot)
138
+ return;
139
+ const { seriesExternalId, originalStartsAt } = fixture.cancelledSlot;
140
+ const listed = await fullList(fixture);
141
+ const all = await everything(fixture, listed);
142
+ const slot = all.find((one) => one.seriesExternalId === seriesExternalId && one.originalStartsAt === originalStartsAt);
143
+ // A source the engine expands locally says it with an EXDATE line of the series instead.
144
+ const excluded = fixture.source.traits.expands === "local" &&
145
+ all.some((one) => one.kind === "series" &&
146
+ one.externalId === seriesExternalId &&
147
+ (one.recurrence ?? []).some((line) => line.startsWith("EXDATE")));
148
+ assert.ok(excluded || slot?.status === "cancelled" || (slot !== undefined && listed.removed.includes(slot.externalId)), "the cancelled slot is missing");
149
+ }),
150
+ test("a dead cursor gives a reset", async (fixture) => {
151
+ if (fixture.deadCursor === undefined)
152
+ return;
153
+ const [first] = await fixture.source.calendars();
154
+ assert.equal(await round(fixture.source, storedOf(first), fixture.deadCursor, fixture.window), "reset");
155
+ }),
156
+ test("a second round with the cursor returns only the changes", async (fixture) => {
157
+ if (fixture.source.traits.fullListOnly)
158
+ return;
159
+ const listed = await fullList(fixture);
160
+ assert.notEqual(listed.cursor, null, "the last page carries a cursor");
161
+ await fixture.afterFullList?.();
162
+ const second = await round(fixture.source, listed.calendar, listed.cursor, fixture.window);
163
+ assert.notEqual(second, "reset");
164
+ const { entries, removed } = second;
165
+ if (fixture.changed)
166
+ assert.deepEqual([...entries.map(({ externalId }) => externalId), ...removed].sort(), [...fixture.changed].sort());
167
+ if (fixture.movedOut)
168
+ assert.ok(!removed.includes(fixture.movedOut), "an entry outside the window is no removal");
169
+ }),
170
+ ];
171
+ };
@@ -0,0 +1,38 @@
1
+ import type { CalendarInput, EntryInput } from "../../meetings/index.js";
2
+ import type { CalendarSource, CalendarSourceTraits, SyncWindow } from "../source.js";
3
+ export interface FakeCalendarSourceOptions {
4
+ provider?: string;
5
+ traits?: Partial<CalendarSourceTraits>;
6
+ calendars?: CalendarInput[];
7
+ /** Entries per page of `changes()`. */
8
+ pageSize?: number;
9
+ }
10
+ /**
11
+ * A provider held in memory. Every `put` and `remove` is one change; the cursor names the last change it saw, so a
12
+ * later `changes()` returns only what came after it. A source whose cursor holds the window refuses a cursor asked
13
+ * for another window, as Graph's delta link would silently keep the old one.
14
+ */
15
+ export interface FakeCalendarSource extends CalendarSource {
16
+ /** Adds or replaces entries of one calendar, by `externalId`. */
17
+ put(calendar: string, ...entries: EntryInput[]): void;
18
+ /** Deletes entries at the provider and reports them in the next round's `removed`. */
19
+ remove(calendar: string, ...externalIds: string[]): void;
20
+ /** Deletes entries without a word: only a full list notices. */
21
+ drop(calendar: string, ...externalIds: string[]): void;
22
+ /** What `occurrences()` returns for a series (`expands: "on-request"`). */
23
+ setOccurrences(seriesExternalId: string, entries: EntryInput[]): void;
24
+ /** Every cursor given out so far is dead: the next round with one gets `{ reset: true }`. */
25
+ expireCursors(): void;
26
+ /** The `changes()` call after this many more succeeds throws once. */
27
+ failAfter(calls: number): void;
28
+ readonly calls: {
29
+ changes: {
30
+ calendar: string;
31
+ cursor: string | null;
32
+ page?: string;
33
+ window: SyncWindow;
34
+ }[];
35
+ occurrences: string[];
36
+ };
37
+ }
38
+ export declare const fakeCalendarSource: (options?: FakeCalendarSourceOptions) => FakeCalendarSource;
@@ -0,0 +1,110 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ const DEFAULT_TRAITS = { expands: "on-request", cursorHoldsWindow: false, fullListOnly: false };
3
+ const inside = (entry, window) => entry.kind === "series" ||
4
+ entry.startsAt == null ||
5
+ (entry.startsAt < window.to && (entry.endsAt ?? entry.startsAt + 1) > window.from);
6
+ export const fakeCalendarSource = (options = {}) => {
7
+ const traits = { ...DEFAULT_TRAITS, ...options.traits };
8
+ const pageSize = options.pageSize ?? 50;
9
+ const calendars = options.calendars ?? [
10
+ {
11
+ externalId: "primary",
12
+ title: "Example work",
13
+ timezone: "UTC",
14
+ accessRole: "owner",
15
+ primary: true,
16
+ syncByDefault: true,
17
+ },
18
+ ];
19
+ const held = new Map();
20
+ const log = [];
21
+ const occurrences = new Map();
22
+ let version = 0;
23
+ let deadBefore = 0;
24
+ let failIn = null;
25
+ const calls = { changes: [], occurrences: [] };
26
+ const of = (calendar) => {
27
+ if (!held.has(calendar))
28
+ held.set(calendar, new Map());
29
+ return held.get(calendar);
30
+ };
31
+ const token = (window) => traits.fullListOnly ? null : `v${version}${traits.cursorHoldsWindow ? `@${window.from}-${window.to}` : ""}`;
32
+ return {
33
+ provider: options.provider ?? "example-calendar",
34
+ traits,
35
+ calls,
36
+ async calendars() {
37
+ return calendars.map((one) => ({ ...one }));
38
+ },
39
+ async changes(calendar, { cursor, page, window }) {
40
+ calls.changes.push({ calendar: calendar.externalId, cursor, window, ...(page === undefined ? {} : { page }) });
41
+ if (failIn !== null && failIn-- === 0) {
42
+ failIn = null;
43
+ throw new CliError("provider_error", "example provider failed");
44
+ }
45
+ let entries;
46
+ let removed = [];
47
+ if (cursor === null || traits.fullListOnly)
48
+ entries = [...of(calendar.externalId).values()];
49
+ else {
50
+ const [, seen, holds] = /^v(\d+)(?:@(.*))?$/u.exec(cursor) ?? [];
51
+ if (seen === undefined || Number(seen) < deadBefore)
52
+ return { reset: true };
53
+ if (traits.cursorHoldsWindow && holds !== `${window.from}-${window.to}`)
54
+ throw new CliError("validation_error", "this cursor holds another window");
55
+ const after = log.filter((one) => one.version > Number(seen) && one.calendar === calendar.externalId);
56
+ const changed = new Map();
57
+ for (const one of after)
58
+ if (one.put)
59
+ changed.set(one.put.externalId, one.put);
60
+ removed = after.flatMap(({ removed }) => (removed === undefined ? [] : [removed]));
61
+ entries = [...changed.values()].filter(({ externalId }) => !removed.includes(externalId));
62
+ }
63
+ if (cursor === null || traits.fullListOnly)
64
+ entries = entries.filter((entry) => inside(entry, window));
65
+ const from = page === undefined ? 0 : Number(page);
66
+ const slice = entries.slice(from, from + pageSize);
67
+ const more = from + pageSize < entries.length;
68
+ return {
69
+ entries: slice.map((entry) => structuredClone(entry)),
70
+ removed: from === 0 ? removed : [],
71
+ ...(more ? { nextPage: String(from + pageSize) } : { cursor: token(window) }),
72
+ };
73
+ },
74
+ ...(traits.expands === "on-request"
75
+ ? {
76
+ async occurrences(_calendar, seriesExternalId, window) {
77
+ calls.occurrences.push(seriesExternalId);
78
+ return (occurrences.get(seriesExternalId) ?? [])
79
+ .filter((entry) => inside(entry, window))
80
+ .map((entry) => structuredClone(entry));
81
+ },
82
+ }
83
+ : {}),
84
+ put(calendar, ...entries) {
85
+ for (const entry of entries) {
86
+ of(calendar).set(entry.externalId, structuredClone(entry));
87
+ log.push({ version: ++version, calendar, put: structuredClone(entry) });
88
+ }
89
+ },
90
+ remove(calendar, ...externalIds) {
91
+ for (const externalId of externalIds) {
92
+ of(calendar).delete(externalId);
93
+ log.push({ version: ++version, calendar, removed: externalId });
94
+ }
95
+ },
96
+ drop(calendar, ...externalIds) {
97
+ for (const externalId of externalIds)
98
+ of(calendar).delete(externalId);
99
+ },
100
+ setOccurrences(seriesExternalId, entries) {
101
+ occurrences.set(seriesExternalId, entries);
102
+ },
103
+ expireCursors() {
104
+ deadBefore = ++version;
105
+ },
106
+ failAfter(n) {
107
+ failIn = n;
108
+ },
109
+ };
110
+ };
@@ -0,0 +1,15 @@
1
+ import type { ContractCase, EventStore } from "../../meetings/index.js";
2
+ import type { CalendarSourceTraits } from "../source.js";
3
+ /** A fresh, empty store and one calendar account in it. */
4
+ export interface CalendarSyncFixture {
5
+ store: EventStore;
6
+ accountId: number;
7
+ }
8
+ /** Every combination of the source traits: the engine must handle each. */
9
+ export declare const CALENDAR_TRAIT_COMBINATIONS: readonly CalendarSourceTraits[];
10
+ /**
11
+ * The sync engine over `fakeCalendarSource`, once for each trait combination: changes after a cursor, removal by id,
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.
14
+ */
15
+ export declare const calendarSyncContract: (make: () => CalendarSyncFixture | Promise<CalendarSyncFixture>) => ContractCase[];
@@ -0,0 +1,211 @@
1
+ import assert from "node:assert/strict";
2
+ import { parseCalendarCursor, syncCalendars } from "../sync.js";
3
+ import { fakeCalendarSource } from "./source.js";
4
+ /** Every combination of the source traits: the engine must handle each. */
5
+ export const CALENDAR_TRAIT_COMBINATIONS = ["on-request", "in-changes", "local"].flatMap((expands) => [false, true].flatMap((cursorHoldsWindow) => [false, true].map((fullListOnly) => ({ expands, cursorHoldsWindow, fullListOnly }))));
6
+ const HOUR = 3_600_000;
7
+ const DAY = 24 * HOUR;
8
+ const NOW = Date.UTC(2026, 9, 27, 9);
9
+ const AT = NOW + DAY;
10
+ const single = (externalId, at = AT, extra = {}) => ({
11
+ externalId,
12
+ kind: "single",
13
+ status: "confirmed",
14
+ title: "Example review",
15
+ startsAt: at,
16
+ endsAt: at + HOUR,
17
+ ...extra,
18
+ });
19
+ const utc = (at) => new Date(at).toISOString().replace(/[-:]|\.\d{3}/gu, "");
20
+ const slotId = (series, at) => `${series}_${utc(at)}`;
21
+ const slot = (series, at, status = "confirmed") => ({
22
+ ...single(slotId(series, at), at, { status }),
23
+ kind: "occurrence",
24
+ seriesExternalId: series,
25
+ originalStartsAt: at,
26
+ icalUid: `${series}@example.com`,
27
+ });
28
+ /** Daily slots of `RRULE:FREQ=DAILY;COUNT=n`; an `EXDATE` slot comes back cancelled. Enough for the contract. */
29
+ const exampleExpander = (series, window) => {
30
+ const count = Number(/COUNT=(\d+)/u.exec(series.recurrence?.find((line) => line.startsWith("RRULE")) ?? "")?.[1]);
31
+ const skipped = new Set((series.recurrence ?? []).filter((line) => line.startsWith("EXDATE")).map((line) => line.split(":")[1]));
32
+ return Array.from({ length: count }, (_, n) => series.startsAt + n * DAY)
33
+ .filter((at) => at >= window.from && at < window.to)
34
+ .map((at) => slot(series.externalId, at, skipped.has(utc(at)) ? "cancelled" : "confirmed"));
35
+ };
36
+ const seriesOf = (externalId, exdates = []) => ({
37
+ ...single(externalId),
38
+ kind: "series",
39
+ icalUid: `${externalId}@example.com`,
40
+ recurrence: ["RRULE:FREQ=DAILY;COUNT=3", ...exdates.map((at) => `EXDATE:${utc(at)}`)],
41
+ });
42
+ /** Every stored entry that belongs to an event, found through the store's own reads. */
43
+ const entriesOf = async (store) => {
44
+ const found = new Map();
45
+ for (const event of await store.events({ limit: 1000 }))
46
+ for (const entry of (await store.event(event.id))?.entries ?? [])
47
+ found.set(entry.externalId, entry);
48
+ return found;
49
+ };
50
+ const nameOf = ({ expands, cursorHoldsWindow, fullListOnly }) => `[expands ${expands}${cursorHoldsWindow ? ", cursor holds the window" : ""}${fullListOnly ? ", full list only" : ""}]`;
51
+ /**
52
+ * The sync engine over `fakeCalendarSource`, once for each trait combination: changes after a cursor, removal by id,
53
+ * 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
+ */
56
+ export const calendarSyncContract = (make) => CALENDAR_TRAIT_COMBINATIONS.flatMap((traits) => {
57
+ const test = (name, check, options = {}) => ({
58
+ name: `${name} ${nameOf(traits)}`,
59
+ async run() {
60
+ const fixture = await make();
61
+ const source = fakeCalendarSource({ ...options, traits });
62
+ const sync = (at, extra = {}) => syncCalendars(source, fixture.store, {
63
+ accountId: fixture.accountId,
64
+ now: at,
65
+ ...(traits.expands === "local" ? { expand: exampleExpander } : {}),
66
+ ...extra,
67
+ });
68
+ await check({ ...fixture, source, sync });
69
+ },
70
+ });
71
+ const cursorOf = async (store, calendarId) => parseCalendarCursor(await store.cursor(calendarId));
72
+ const putSeries = (source, series, slots) => {
73
+ if (traits.expands === "in-changes")
74
+ source.put("primary", series, ...slots);
75
+ else
76
+ source.put("primary", series);
77
+ if (traits.expands === "on-request")
78
+ source.setOccurrences(series.externalId, slots);
79
+ };
80
+ return [
81
+ test("the first run lists the window; the next reads only what changed", async ({ store, source, sync }) => {
82
+ source.put("primary", single("e1"), single("e2"));
83
+ const first = await sync(NOW);
84
+ const [one] = first.synced;
85
+ assert.deepEqual([one?.round, one?.saved, first.complete], ["full", 2, true]);
86
+ const cursor = await cursorOf(store, one?.calendarId);
87
+ assert.equal(cursor?.fullAt, NOW);
88
+ assert.equal(cursor?.token === null, traits.fullListOnly);
89
+ source.put("primary", single("e3"));
90
+ const second = (await sync(NOW + HOUR)).synced[0];
91
+ assert.equal(second?.round, traits.fullListOnly ? "full" : "changes");
92
+ assert.equal(second?.saved, traits.fullListOnly ? 3 : 1);
93
+ assert.equal(source.calls.changes.at(-1)?.cursor === null, traits.fullListOnly);
94
+ assert.deepEqual([...(await entriesOf(store)).keys()].sort(), ["e1", "e2", "e3"]);
95
+ }),
96
+ test("an entry the provider deletes is cancelled, or marked when only a full list shows it", async ({ store, source, sync, }) => {
97
+ source.put("primary", single("e1"));
98
+ await sync(NOW);
99
+ source.remove("primary", "e1");
100
+ const after = (await sync(NOW + HOUR)).synced[0];
101
+ const entry = (await entriesOf(store)).get("e1");
102
+ if (traits.fullListOnly) {
103
+ assert.equal(after?.unseen, 1);
104
+ assert.equal(entry?.deletedAt, NOW + HOUR);
105
+ }
106
+ else {
107
+ assert.equal(after?.removed, 1);
108
+ assert.deepEqual([entry?.status, entry?.deletedAt], ["cancelled", null]);
109
+ }
110
+ }),
111
+ test("a dead cursor is forgotten and the window listed again", async ({ store, source, sync }) => {
112
+ source.put("primary", single("e1"));
113
+ await sync(NOW);
114
+ source.expireCursors();
115
+ source.put("primary", single("e2"));
116
+ const after = (await sync(NOW + HOUR)).synced[0];
117
+ assert.equal(after?.round, "full");
118
+ assert.equal(after?.reset, !traits.fullListOnly);
119
+ assert.deepEqual(source.calls.changes.slice(traits.fullListOnly ? -1 : -2).map(({ cursor }) => cursor === null), traits.fullListOnly ? [true] : [false, true]);
120
+ assert.equal((await cursorOf(store, after?.calendarId))?.fullAt, NOW + HOUR);
121
+ assert.deepEqual([...(await entriesOf(store)).keys()].sort(), ["e1", "e2"]);
122
+ }),
123
+ test("a window that moved means a full round only when the cursor holds the window", async ({ store, source, sync, }) => {
124
+ const window = { from: NOW - 30 * DAY, to: NOW + 30 * DAY };
125
+ source.put("primary", single("e1"));
126
+ await sync(NOW, { window });
127
+ const moved = await sync(NOW + HOUR, { window: { from: window.from + DAY, to: window.to + DAY } });
128
+ assert.equal(moved.complete, true);
129
+ assert.equal(moved.synced[0]?.round, traits.cursorHoldsWindow || traits.fullListOnly ? "full" : "changes");
130
+ assert.deepEqual((await cursorOf(store, moved.synced[0]?.calendarId))?.window, {
131
+ from: window.from + DAY,
132
+ to: window.to + DAY,
133
+ });
134
+ }),
135
+ test("a series' slots come as the source expands them", async ({ store, source, sync }) => {
136
+ const slots = [slot("s", AT), slot("s", AT + DAY, "cancelled"), slot("s", AT + 2 * DAY)];
137
+ putSeries(source, seriesOf("s", [AT + DAY]), slots);
138
+ const report = (await sync(NOW)).synced[0];
139
+ assert.equal(report?.occurrences, traits.expands === "in-changes" ? 0 : 3);
140
+ assert.deepEqual(source.calls.occurrences, traits.expands === "on-request" ? ["s"] : []);
141
+ const entries = await entriesOf(store);
142
+ assert.deepEqual([slotId("s", AT), slotId("s", AT + 2 * DAY)].map((id) => entries.get(id)?.status), ["confirmed", "confirmed"]);
143
+ assert.equal(entries.get(slotId("s", AT))?.seriesExternalId, "s");
144
+ }),
145
+ test("a slot cancelled after it was stored is cancelled, never deleted", async ({ store, source, sync }) => {
146
+ putSeries(source, seriesOf("s"), [slot("s", AT), slot("s", AT + DAY), slot("s", AT + 2 * DAY)]);
147
+ await sync(NOW);
148
+ if (traits.expands === "in-changes")
149
+ source.put("primary", slot("s", AT + DAY, "cancelled"));
150
+ else
151
+ putSeries(source, seriesOf("s", [AT + DAY]), [
152
+ slot("s", AT),
153
+ slot("s", AT + DAY, "cancelled"),
154
+ slot("s", AT + 2 * DAY),
155
+ ]);
156
+ await sync(NOW + HOUR);
157
+ const entry = (await entriesOf(store)).get(slotId("s", AT + DAY));
158
+ assert.deepEqual([entry?.status, entry?.deletedAt], ["cancelled", null]);
159
+ const event = await store.event(entry?.eventId);
160
+ assert.equal(event?.event.status, "cancelled");
161
+ }),
162
+ test("a failed page keeps the old cursor, and the next run reads the same changes again", async ({ store, source, sync }) => {
163
+ source.put("primary", single("e1"));
164
+ const first = await sync(NOW);
165
+ const calendarId = first.synced[0]?.calendarId;
166
+ const before = await store.cursor(calendarId);
167
+ source.put("primary", single("e2"), single("e3"));
168
+ source.failAfter(1);
169
+ const failed = await sync(NOW + HOUR);
170
+ assert.equal(failed.complete, false);
171
+ assert.match(failed.synced[0]?.error ?? "", /example provider failed/u);
172
+ assert.equal(await store.cursor(calendarId), before);
173
+ const again = await sync(NOW + 2 * HOUR);
174
+ assert.equal(again.complete, true);
175
+ assert.deepEqual([...(await entriesOf(store)).keys()].sort(), ["e1", "e2", "e3"]);
176
+ }, { pageSize: 1 }),
177
+ test("a calendar the owner does not sync is listed and never read", async ({ source, sync }) => {
178
+ const report = await sync(NOW);
179
+ assert.deepEqual(report.notSynced.map(({ externalId }) => externalId), ["team"]);
180
+ assert.deepEqual(new Set(source.calls.changes.map(({ calendar }) => calendar)), new Set(["primary"]));
181
+ }, {
182
+ calendars: [
183
+ {
184
+ externalId: "primary",
185
+ title: "Work",
186
+ timezone: "UTC",
187
+ accessRole: "owner",
188
+ primary: true,
189
+ syncByDefault: true,
190
+ },
191
+ {
192
+ externalId: "team",
193
+ title: "Team",
194
+ timezone: null,
195
+ accessRole: "reader",
196
+ primary: false,
197
+ syncByDefault: false,
198
+ },
199
+ ],
200
+ }),
201
+ test("a full run marks what the provider dropped without a word", async ({ store, source, sync }) => {
202
+ source.put("primary", single("e1"), single("e2"));
203
+ await sync(NOW);
204
+ source.drop("primary", "e2");
205
+ const after = (await sync(NOW + HOUR, { full: true })).synced[0];
206
+ assert.deepEqual([after?.round, after?.unseen], ["full", 1]);
207
+ const entries = await entriesOf(store);
208
+ assert.deepEqual([entries.get("e1")?.deletedAt, entries.get("e2")?.deletedAt], [null, NOW + HOUR]);
209
+ }),
210
+ ];
211
+ });
@@ -53,6 +53,7 @@ const OTHERS = [
53
53
  validation("push.unreadable", "the folder to push could not be read"),
54
54
  validation("spreadsheet.unreadable", "the workbook could not be read"),
55
55
  ["state.invalid", "configuration_error", "the saved sync state is unreadable or invalid; it is kept, not reset"],
56
+ ["sync.partial", "provider_error", "some calendars did not sync; the next sync retries them"],
56
57
  validation("store.refused", "the local store refused the document text"),
57
58
  ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
58
59
  [
@@ -9,5 +9,5 @@ export declare const calendarText: (text: string | null | undefined, { dropSecre
9
9
  dropSecretLines?: boolean;
10
10
  }) => string | null;
11
11
  /** The provider fields a calendar entry may keep in `metadata`: none of them can open a meeting. */
12
- export declare const ENTRY_METADATA_KEYS: readonly ["colorId", "guestsCanInviteOthers", "guestsCanModify", "guestsCanSeeOtherGuests", "hangoutLink", "locked", "privateCopy", "recurring_event_id"];
12
+ export declare const ENTRY_METADATA_KEYS: readonly ["colorId", "guestsCanInviteOthers", "guestsCanModify", "guestsCanSeeOtherGuests", "hangoutLink", "locked", "privateCopy"];
13
13
  export declare const entryMetadata: (metadata: Record<string, unknown> | null | undefined) => Record<string, unknown>;
@@ -42,7 +42,6 @@ export const ENTRY_METADATA_KEYS = [
42
42
  "hangoutLink",
43
43
  "locked",
44
44
  "privateCopy",
45
- "recurring_event_id",
46
45
  ];
47
46
  export const entryMetadata = (metadata) => {
48
47
  const kept = {};