@wirecat/cli-core 0.463.0 → 0.464.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/dist/background/after-upgrade.d.ts +51 -0
- package/dist/background/after-upgrade.js +245 -0
- package/dist/background/budget.d.ts +37 -0
- package/dist/background/budget.js +48 -0
- package/dist/background/index.d.ts +1 -0
- package/dist/background/index.js +1 -0
- package/dist/calendar/busy.d.ts +68 -0
- package/dist/calendar/busy.js +148 -0
- package/dist/calendar/cli/index.d.ts +7 -0
- package/dist/calendar/cli/index.js +94 -12
- package/dist/calendar/index.d.ts +3 -2
- package/dist/calendar/index.js +2 -1
- package/dist/calendar/mcp/index.d.ts +55 -13
- package/dist/calendar/mcp/index.js +19 -4
- package/dist/calendar/recurrence.d.ts +10 -0
- package/dist/calendar/recurrence.js +65 -0
- package/dist/calendar/source.d.ts +19 -0
- package/dist/calendar/sync.d.ts +86 -7
- package/dist/calendar/sync.js +271 -105
- package/dist/calendar/testing/source-contract.js +14 -0
- package/dist/calendar/testing/source.d.ts +6 -0
- package/dist/calendar/testing/source.js +30 -2
- package/dist/calendar/testing/sync-contract.d.ts +2 -1
- package/dist/calendar/testing/sync-contract.js +80 -5
- package/dist/calendar/testing/writer-contract.d.ts +2 -1
- package/dist/calendar/testing/writer-contract.js +46 -2
- package/dist/calendar/testing/writer.js +1 -1
- package/dist/calendar/writer.d.ts +7 -2
- package/dist/calendar/writes.d.ts +12 -2
- package/dist/calendar/writes.js +32 -6
- package/dist/cli/context.d.ts +8 -0
- package/dist/cli/context.js +15 -0
- package/dist/cli/execution.d.ts +7 -0
- package/dist/cli/execution.js +7 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/mcp-server-command.js +16 -10
- package/dist/cli/meeting-preparation-answer.d.ts +1 -0
- package/dist/cli/meeting-preparation-projects.d.ts +1 -0
- package/dist/cli/meeting-preparation-read.d.ts +1 -0
- package/dist/cli/messenger/chat-answers.js +1 -0
- package/dist/cli/messenger/search-answers.js +29 -4
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/program.js +15 -5
- package/dist/cli/upgrade-command.d.ts +7 -2
- package/dist/cli/upgrade-command.js +65 -9
- package/dist/entities/fields.d.ts +2 -0
- package/dist/entities/fields.js +2 -0
- package/dist/entities/hit.d.ts +1 -1
- package/dist/entities/hit.js +3 -3
- package/dist/entities/index.d.ts +10 -800
- package/dist/entities/index.js +37 -31
- package/dist/entities/meeting-preparation.d.ts +1 -0
- package/dist/entities/topic.d.ts +1 -0
- package/dist/entities/topic.js +2 -1
- package/dist/errors/reasons.js +4 -0
- package/dist/mcp/http/state.d.ts +14 -0
- package/dist/mcp/http/state.js +16 -0
- package/dist/mcp/meeting-preparation.d.ts +1 -0
- package/dist/meetings/answers.d.ts +2 -0
- package/dist/meetings/answers.js +3 -0
- package/dist/meetings/mcp/search.d.ts +1 -0
- package/dist/meetings/project-evidence-answer.d.ts +1 -0
- package/dist/ports/app.d.ts +5 -0
- package/dist/services/package-upgrade.d.ts +9 -0
- package/dist/services/package-upgrade.js +8 -1
- package/package.json +3 -3
package/dist/calendar/sync.js
CHANGED
|
@@ -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
|
|
9
|
-
if (
|
|
10
|
-
(
|
|
11
|
-
typeof
|
|
12
|
-
|
|
13
|
-
|
|
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:
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
117
|
-
*
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
143
|
-
|
|
279
|
+
stopped = await syncOne({
|
|
280
|
+
source,
|
|
281
|
+
store,
|
|
282
|
+
calendar,
|
|
144
283
|
window,
|
|
145
284
|
now,
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
171
|
+
assert.deepEqual([failed.complete, failed.state], [false, "partial"]);
|
|
171
172
|
assert.match(failed.synced[0]?.error ?? "", /example provider failed/u);
|
|
172
|
-
|
|
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"]);
|