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