@remit/calendar-service 0.0.1

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.
@@ -0,0 +1,512 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ CalendarCollectionItem,
5
+ CalendarEventIndexItem,
6
+ CalendarObjectItem,
7
+ CalendarOccurrenceInput,
8
+ CreateCalendarCollectionInput,
9
+ ICalendarCollectionRepository,
10
+ ICalendarEventIndexRepository,
11
+ ICalendarObjectRepository,
12
+ ICalendarUnitOfWork,
13
+ PutCalendarObjectInput as PutCalendarObjectRow,
14
+ UpdateCalendarCollectionInput,
15
+ } from "@remit/data-ports";
16
+ import {
17
+ deriveCalendarId,
18
+ deriveCalendarObjectId,
19
+ normalizeCalendarUrlSegment,
20
+ } from "@remit/data-ports/id";
21
+ import { computeEtag } from "./etag.js";
22
+ import { asLf, singleEvent } from "./fixtures.js";
23
+ import { parseCalendar, serializeCalendar } from "./parse.js";
24
+ import {
25
+ DEFAULT_CALENDAR_URL_SEGMENT,
26
+ deleteCalendarObject,
27
+ provisionDefaultCalendar,
28
+ putCalendarObject,
29
+ } from "./put.js";
30
+
31
+ class MissingRow extends Error {}
32
+
33
+ /**
34
+ * A pass-through unit of work over plain maps — the shape the port documents
35
+ * for a backend with no cross-entity transaction. It proves the write path
36
+ * calls what it should, in the order it should; that the three writes stand or
37
+ * fall together is proven against a real transaction, in
38
+ * drizzle-service's calendar-put.sqlite.test.ts.
39
+ */
40
+ class MemoryCalendarStore implements ICalendarUnitOfWork {
41
+ readonly collections = new Map<string, CalendarCollectionItem>();
42
+ readonly objects = new Map<string, CalendarObjectItem>();
43
+ readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
44
+
45
+ private readonly collectionRepo: ICalendarCollectionRepository = {
46
+ create: async (input: CreateCalendarCollectionInput) => {
47
+ const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
48
+ const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
49
+ const existing = this.collections.get(calendarId);
50
+ if (existing) return existing;
51
+ const now = Date.now();
52
+ const collection: CalendarCollectionItem = {
53
+ calendarId,
54
+ accountConfigId: input.accountConfigId,
55
+ urlSegment,
56
+ displayName: input.displayName,
57
+ color: input.color ?? "Cal1",
58
+ componentSet: input.componentSet ?? "VeventOnly",
59
+ source: input.source ?? "UserCreated",
60
+ timezone: input.timezone ?? "",
61
+ syncSequence: 0,
62
+ createdAt: now,
63
+ updatedAt: now,
64
+ };
65
+ this.collections.set(calendarId, collection);
66
+ return collection;
67
+ },
68
+ get: async (_accountConfigId: string, calendarId: string) => {
69
+ const collection = this.collections.get(calendarId);
70
+ if (!collection) throw new MissingRow(calendarId);
71
+ return collection;
72
+ },
73
+ update: async (
74
+ _accountConfigId: string,
75
+ calendarId: string,
76
+ input: UpdateCalendarCollectionInput,
77
+ ) => {
78
+ const collection = this.collections.get(calendarId);
79
+ if (!collection) throw new MissingRow(calendarId);
80
+ const updated = { ...collection, ...input };
81
+ this.collections.set(calendarId, updated);
82
+ return updated;
83
+ },
84
+ delete: async (_accountConfigId: string, calendarId: string) => {
85
+ this.collections.delete(calendarId);
86
+ },
87
+ listByAccountConfig: async (accountConfigId: string) =>
88
+ [...this.collections.values()].filter(
89
+ (collection) => collection.accountConfigId === accountConfigId,
90
+ ),
91
+ findByUrlSegment: async (accountConfigId: string, urlSegment: string) =>
92
+ this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
93
+ null,
94
+ bumpSyncSequence: async (_accountConfigId: string, calendarId: string) => {
95
+ const collection = this.collections.get(calendarId);
96
+ if (!collection) throw new MissingRow(calendarId);
97
+ const bumped = {
98
+ ...collection,
99
+ syncSequence: collection.syncSequence + 1,
100
+ };
101
+ this.collections.set(calendarId, bumped);
102
+ return bumped.syncSequence;
103
+ },
104
+ };
105
+
106
+ private readonly objectRepo: ICalendarObjectRepository = {
107
+ put: async (input: PutCalendarObjectRow) => {
108
+ const calendarObjectId = deriveCalendarObjectId(
109
+ input.calendarId,
110
+ input.resourceName,
111
+ );
112
+ const now = Date.now();
113
+ const object: CalendarObjectItem = {
114
+ ...input,
115
+ calendarObjectId,
116
+ createdAt: this.objects.get(calendarObjectId)?.createdAt ?? now,
117
+ updatedAt: now,
118
+ };
119
+ this.objects.set(calendarObjectId, object);
120
+ return object;
121
+ },
122
+ get: async (_calendarId: string, calendarObjectId: string) => {
123
+ const object = this.objects.get(calendarObjectId);
124
+ if (!object) throw new MissingRow(calendarObjectId);
125
+ return object;
126
+ },
127
+ delete: async (_calendarId: string, calendarObjectId: string) => {
128
+ this.objects.delete(calendarObjectId);
129
+ },
130
+ findByResourceName: async (calendarId: string, resourceName: string) =>
131
+ this.objects.get(deriveCalendarObjectId(calendarId, resourceName)) ??
132
+ null,
133
+ findByUid: async (calendarId: string, icalUid: string) =>
134
+ [...this.objects.values()].find(
135
+ (object) =>
136
+ object.calendarId === calendarId && object.icalUid === icalUid,
137
+ ) ?? null,
138
+ listByCalendar: async (calendarId: string) =>
139
+ [...this.objects.values()].filter(
140
+ (object) => object.calendarId === calendarId,
141
+ ),
142
+ listChangedSince: async (calendarId: string, syncSequence: number) =>
143
+ [...this.objects.values()]
144
+ .filter(
145
+ (object) =>
146
+ object.calendarId === calendarId &&
147
+ object.syncSequence > syncSequence,
148
+ )
149
+ .sort((left, right) => left.syncSequence - right.syncSequence),
150
+ };
151
+
152
+ private readonly eventIndexRepo: ICalendarEventIndexRepository = {
153
+ replaceForObject: async (
154
+ calendarId: string,
155
+ calendarObjectId: string,
156
+ occurrences: CalendarOccurrenceInput[],
157
+ ) => {
158
+ const now = Date.now();
159
+ this.occurrences.set(
160
+ calendarObjectId,
161
+ occurrences.map((occurrence) => ({
162
+ ...occurrence,
163
+ calendarId,
164
+ calendarObjectId,
165
+ createdAt: now,
166
+ updatedAt: now,
167
+ })),
168
+ );
169
+ },
170
+ deleteForObject: async (_calendarId: string, calendarObjectId: string) => {
171
+ this.occurrences.delete(calendarObjectId);
172
+ },
173
+ listForObject: async (_calendarId: string, calendarObjectId: string) =>
174
+ this.occurrences.get(calendarObjectId) ?? [],
175
+ listByStartRange: async (
176
+ calendarId: string,
177
+ startAt: string,
178
+ endAt: string,
179
+ ) =>
180
+ [...this.occurrences.values()]
181
+ .flat()
182
+ .filter(
183
+ (row) =>
184
+ row.calendarId === calendarId &&
185
+ row.startAt >= startAt &&
186
+ row.startAt < endAt,
187
+ )
188
+ .sort((left, right) => left.startAt.localeCompare(right.startAt)),
189
+ };
190
+
191
+ transaction<T>(
192
+ fn: (repos: {
193
+ calendarCollection: ICalendarCollectionRepository;
194
+ calendarObject: ICalendarObjectRepository;
195
+ calendarEventIndex: ICalendarEventIndexRepository;
196
+ }) => Promise<T>,
197
+ ): Promise<T> {
198
+ return fn({
199
+ calendarCollection: this.collectionRepo,
200
+ calendarObject: this.objectRepo,
201
+ calendarEventIndex: this.eventIndexRepo,
202
+ });
203
+ }
204
+ }
205
+
206
+ const ACCOUNT_CONFIG_ID = "account-config-1";
207
+
208
+ const provisioned = async (): Promise<{
209
+ store: MemoryCalendarStore;
210
+ calendarId: string;
211
+ }> => {
212
+ const store = new MemoryCalendarStore();
213
+ const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
214
+ return { store, calendarId: collection.calendarId };
215
+ };
216
+
217
+ const RESOURCE = singleEvent(
218
+ "DTSTART:20260826T090000Z",
219
+ "DTEND:20260826T100000Z",
220
+ "SUMMARY:Quarterly review",
221
+ );
222
+
223
+ describe("provisionDefaultCalendar", () => {
224
+ it("gives an account config a default collection at the default segment", async () => {
225
+ const store = new MemoryCalendarStore();
226
+
227
+ const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
228
+
229
+ assert.equal(collection.urlSegment, DEFAULT_CALENDAR_URL_SEGMENT);
230
+ assert.equal(collection.source, "Default");
231
+ assert.equal(collection.displayName, "Calendar");
232
+ });
233
+
234
+ it("returns the same collection on a second first use", async () => {
235
+ const store = new MemoryCalendarStore();
236
+
237
+ const first = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
238
+ const second = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
239
+
240
+ assert.equal(second.calendarId, first.calendarId);
241
+ assert.equal(store.collections.size, 1);
242
+ });
243
+ });
244
+
245
+ describe("putCalendarObject", () => {
246
+ it("stores the bytes it was given, untouched", async () => {
247
+ const { store, calendarId } = await provisioned();
248
+
249
+ const result = await putCalendarObject(store, {
250
+ accountConfigId: ACCOUNT_CONFIG_ID,
251
+ calendarId,
252
+ resourceName: "review.ics",
253
+ icalData: RESOURCE,
254
+ });
255
+
256
+ assert.ok(result.ok);
257
+ assert.equal(result.value.icalData, RESOURCE);
258
+ assert.equal(result.value.summary, "Quarterly review");
259
+ assert.equal(result.value.icalUid, "fixture@example.com");
260
+ });
261
+
262
+ it("stores the input bytes rather than a reserialization of them", async () => {
263
+ // The etag is a digest of what the writer sent. ical.js refolds and
264
+ // reorders on serialize, so a write path that stored its own
265
+ // reserialization would hand back a tag for bytes nobody wrote, and every
266
+ // client's cached copy would miss on the next read.
267
+ const { store, calendarId } = await provisioned();
268
+ const source = singleEvent(
269
+ "DTSTART:20260826T090000Z",
270
+ "DTEND:20260826T100000Z",
271
+ "SUMMARY:A summary long enough that RFC 5545 line folding rewrites it when ical.js serializes the event again",
272
+ "X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
273
+ );
274
+ const parsed = await parseCalendar(source);
275
+ assert.ok(parsed.ok);
276
+ const reserialized = serializeCalendar(parsed.value.component);
277
+ assert.notEqual(
278
+ reserialized,
279
+ source,
280
+ "the fixture survives reserialization unchanged, so it proves nothing",
281
+ );
282
+
283
+ const result = await putCalendarObject(store, {
284
+ accountConfigId: ACCOUNT_CONFIG_ID,
285
+ calendarId,
286
+ resourceName: "folded.ics",
287
+ icalData: source,
288
+ });
289
+
290
+ assert.ok(result.ok);
291
+ assert.equal(result.value.icalData, source);
292
+ assert.equal(result.value.etag, computeEtag(source));
293
+ assert.notEqual(result.value.etag, computeEtag(reserialized));
294
+ });
295
+
296
+ it("stores an LF-only resource as it arrived, without normalizing it", async () => {
297
+ // The store keeps what the writer sent. Rewriting line endings on the way
298
+ // in would give the resource an etag for bytes nobody wrote, and the
299
+ // writer's own If-Match would miss on its very next request.
300
+ const { store, calendarId } = await provisioned();
301
+ const source = asLf(RESOURCE);
302
+
303
+ const result = await putCalendarObject(store, {
304
+ accountConfigId: ACCOUNT_CONFIG_ID,
305
+ calendarId,
306
+ resourceName: "lf.ics",
307
+ icalData: source,
308
+ });
309
+
310
+ assert.ok(result.ok);
311
+ assert.equal(result.value.icalData, source);
312
+ assert.ok(!result.value.icalData.includes("\r"));
313
+ assert.equal(result.value.etag, computeEtag(source));
314
+ assert.equal(result.value.summary, "Quarterly review");
315
+ assert.equal(result.value.dtStart, "2026-08-26T09:00:00+00:00");
316
+ });
317
+
318
+ it("writes the occurrence rows the resource expands to", async () => {
319
+ const { store, calendarId } = await provisioned();
320
+
321
+ const result = await putCalendarObject(store, {
322
+ accountConfigId: ACCOUNT_CONFIG_ID,
323
+ calendarId,
324
+ resourceName: "series.ics",
325
+ icalData: singleEvent(
326
+ "DTSTART:20260826T090000Z",
327
+ "DTEND:20260826T100000Z",
328
+ "RRULE:FREQ=WEEKLY;COUNT=3",
329
+ ),
330
+ });
331
+
332
+ assert.ok(result.ok);
333
+ assert.equal(
334
+ store.occurrences.get(result.value.calendarObjectId)?.length,
335
+ 3,
336
+ );
337
+ });
338
+
339
+ it("replaces the occurrences of a resource it rewrites", async () => {
340
+ const { store, calendarId } = await provisioned();
341
+ const input = {
342
+ accountConfigId: ACCOUNT_CONFIG_ID,
343
+ calendarId,
344
+ resourceName: "series.ics",
345
+ };
346
+
347
+ await putCalendarObject(store, {
348
+ ...input,
349
+ icalData: singleEvent(
350
+ "DTSTART:20260826T090000Z",
351
+ "DTEND:20260826T100000Z",
352
+ "RRULE:FREQ=WEEKLY;COUNT=5",
353
+ ),
354
+ });
355
+ const result = await putCalendarObject(store, {
356
+ ...input,
357
+ icalData: singleEvent(
358
+ "DTSTART:20260826T090000Z",
359
+ "DTEND:20260826T100000Z",
360
+ "RRULE:FREQ=WEEKLY;COUNT=2",
361
+ ),
362
+ });
363
+
364
+ assert.ok(result.ok);
365
+ assert.equal(store.objects.size, 1);
366
+ assert.equal(
367
+ store.occurrences.get(result.value.calendarObjectId)?.length,
368
+ 2,
369
+ );
370
+ });
371
+
372
+ it("stamps the collection's new sequence on the resource it wrote", async () => {
373
+ const { store, calendarId } = await provisioned();
374
+
375
+ const first = await putCalendarObject(store, {
376
+ accountConfigId: ACCOUNT_CONFIG_ID,
377
+ calendarId,
378
+ resourceName: "one.ics",
379
+ icalData: RESOURCE,
380
+ });
381
+ const second = await putCalendarObject(store, {
382
+ accountConfigId: ACCOUNT_CONFIG_ID,
383
+ calendarId,
384
+ resourceName: "two.ics",
385
+ icalData: singleEvent(
386
+ "DTSTART:20260827T090000Z",
387
+ "DTEND:20260827T100000Z",
388
+ ),
389
+ });
390
+
391
+ assert.ok(first.ok);
392
+ assert.ok(second.ok);
393
+ assert.equal(first.value.syncSequence, 1);
394
+ assert.equal(second.value.syncSequence, 2);
395
+ assert.equal(store.collections.get(calendarId)?.syncSequence, 2);
396
+ });
397
+
398
+ it("computes the etag over the stored bytes", async () => {
399
+ const { store, calendarId } = await provisioned();
400
+
401
+ const result = await putCalendarObject(store, {
402
+ accountConfigId: ACCOUNT_CONFIG_ID,
403
+ calendarId,
404
+ resourceName: "review.ics",
405
+ icalData: RESOURCE,
406
+ });
407
+
408
+ assert.ok(result.ok);
409
+ assert.match(result.value.etag, /^[0-9a-f]{64}$/);
410
+ });
411
+
412
+ it("refuses a resource the collection does not store, and writes nothing", async () => {
413
+ const { store, calendarId } = await provisioned();
414
+
415
+ const result = await putCalendarObject(store, {
416
+ accountConfigId: ACCOUNT_CONFIG_ID,
417
+ calendarId,
418
+ resourceName: "todo.ics",
419
+ icalData: [
420
+ "BEGIN:VCALENDAR",
421
+ "VERSION:2.0",
422
+ "BEGIN:VTODO",
423
+ "UID:todo@example.com",
424
+ "END:VTODO",
425
+ "END:VCALENDAR",
426
+ "",
427
+ ].join("\r\n"),
428
+ });
429
+
430
+ assert.ok(!result.ok);
431
+ assert.equal(result.error.code, "UnsupportedComponent");
432
+ assert.equal(store.objects.size, 0);
433
+ assert.equal(store.collections.get(calendarId)?.syncSequence, 0);
434
+ });
435
+
436
+ it("refuses an event that ends before it starts", async () => {
437
+ const { store, calendarId } = await provisioned();
438
+
439
+ const result = await putCalendarObject(store, {
440
+ accountConfigId: ACCOUNT_CONFIG_ID,
441
+ calendarId,
442
+ resourceName: "backwards.ics",
443
+ icalData: singleEvent(
444
+ "DTSTART:20260826T100000Z",
445
+ "DTEND:20260826T090000Z",
446
+ ),
447
+ });
448
+
449
+ assert.ok(!result.ok);
450
+ assert.equal(result.error.code, "BackwardsEnd");
451
+ assert.equal(store.objects.size, 0);
452
+ });
453
+
454
+ it("fails loudly when the collection does not exist", async () => {
455
+ const store = new MemoryCalendarStore();
456
+
457
+ await assert.rejects(
458
+ putCalendarObject(store, {
459
+ accountConfigId: ACCOUNT_CONFIG_ID,
460
+ calendarId: "no-such-calendar",
461
+ resourceName: "review.ics",
462
+ icalData: RESOURCE,
463
+ }),
464
+ MissingRow,
465
+ );
466
+ });
467
+
468
+ it("projects the event in the collection's timezone", async () => {
469
+ const store = new MemoryCalendarStore();
470
+ const collection = await store.transaction((repos) =>
471
+ repos.calendarCollection.create({
472
+ accountConfigId: ACCOUNT_CONFIG_ID,
473
+ urlSegment: "berlin",
474
+ displayName: "Berlin",
475
+ timezone: "Europe/Berlin",
476
+ }),
477
+ );
478
+
479
+ const result = await putCalendarObject(store, {
480
+ accountConfigId: ACCOUNT_CONFIG_ID,
481
+ calendarId: collection.calendarId,
482
+ resourceName: "floating.ics",
483
+ icalData: singleEvent("DTSTART:20260826T090000", "DTEND:20260826T100000"),
484
+ });
485
+
486
+ assert.ok(result.ok);
487
+ assert.equal(result.value.dtStart, "2026-08-26T09:00:00+02:00");
488
+ });
489
+ });
490
+
491
+ describe("deleteCalendarObject", () => {
492
+ it("takes the occurrences with the resource and bumps the collection", async () => {
493
+ const { store, calendarId } = await provisioned();
494
+ const written = await putCalendarObject(store, {
495
+ accountConfigId: ACCOUNT_CONFIG_ID,
496
+ calendarId,
497
+ resourceName: "review.ics",
498
+ icalData: RESOURCE,
499
+ });
500
+ assert.ok(written.ok);
501
+
502
+ await deleteCalendarObject(store, {
503
+ accountConfigId: ACCOUNT_CONFIG_ID,
504
+ calendarId,
505
+ calendarObjectId: written.value.calendarObjectId,
506
+ });
507
+
508
+ assert.equal(store.objects.size, 0);
509
+ assert.equal(store.occurrences.size, 0);
510
+ assert.equal(store.collections.get(calendarId)?.syncSequence, 2);
511
+ });
512
+ });
package/src/put.ts ADDED
@@ -0,0 +1,123 @@
1
+ import type {
2
+ CalendarCollectionItem,
3
+ CalendarObjectItem,
4
+ ICalendarUnitOfWork,
5
+ } from "@remit/data-ports";
6
+ import { CalendarSource } from "@remit/domain-enums";
7
+ import type { CalendarResult } from "./errors.js";
8
+ import { computeEtag } from "./etag.js";
9
+ import { expandCalendar } from "./expand.js";
10
+ import { parseCalendar } from "./parse.js";
11
+ import { projectCalendar } from "./project.js";
12
+
13
+ /** URL segment of the collection every account config is provisioned with. */
14
+ export const DEFAULT_CALENDAR_URL_SEGMENT = "default";
15
+
16
+ export interface PutCalendarObjectInput {
17
+ accountConfigId: string;
18
+ calendarId: string;
19
+ /** Last path segment of the resource's URL, e.g. `"a1b2c3.ics"`. */
20
+ resourceName: string;
21
+ /** The VCALENDAR text as it arrived, stored byte-for-byte. */
22
+ icalData: string;
23
+ }
24
+
25
+ /**
26
+ * Writes one calendar resource. Every caller that stores an event goes through
27
+ * here — a REST handler, a DAV PUT, an accepted suggestion — because the object
28
+ * row, its occurrence rows and the collection's sequence bump are one fact, and
29
+ * a caller that writes any of them alone leaves the store describing a calendar
30
+ * that does not exist.
31
+ *
32
+ * The bytes are stored exactly as given: parsing is for validating and
33
+ * projecting them, never for rewriting them. Refusing the resource is a
34
+ * returned value — the input is client-supplied, so malformed iCalendar is an
35
+ * outcome the caller renders, not a fault.
36
+ */
37
+ export const putCalendarObject = async (
38
+ unitOfWork: ICalendarUnitOfWork,
39
+ input: PutCalendarObjectInput,
40
+ ): Promise<CalendarResult<CalendarObjectItem>> => {
41
+ const parsed = await parseCalendar(input.icalData);
42
+ if (!parsed.ok) return parsed;
43
+
44
+ return unitOfWork.transaction(async (repos) => {
45
+ const collection = await repos.calendarCollection.get(
46
+ input.accountConfigId,
47
+ input.calendarId,
48
+ );
49
+
50
+ const projection = projectCalendar(parsed.value, collection.timezone);
51
+ if (!projection.ok) return projection;
52
+
53
+ const expansion = expandCalendar(parsed.value, collection.timezone);
54
+ const syncSequence = await repos.calendarCollection.bumpSyncSequence(
55
+ input.accountConfigId,
56
+ input.calendarId,
57
+ );
58
+
59
+ const object = await repos.calendarObject.put({
60
+ ...projection.value,
61
+ calendarId: input.calendarId,
62
+ resourceName: input.resourceName,
63
+ icalData: input.icalData,
64
+ etag: computeEtag(input.icalData),
65
+ syncSequence,
66
+ expandedThrough: expansion.expandedThrough,
67
+ });
68
+
69
+ await repos.calendarEventIndex.replaceForObject(
70
+ input.calendarId,
71
+ object.calendarObjectId,
72
+ expansion.occurrences,
73
+ );
74
+
75
+ return { ok: true, value: object };
76
+ });
77
+ };
78
+
79
+ /**
80
+ * Removes a resource and the occurrences it produced, in one unit — an object
81
+ * dropped without its occurrence rows leaves a calendar showing events no
82
+ * resource backs, which nothing later can attribute or clean up.
83
+ */
84
+ export const deleteCalendarObject = async (
85
+ unitOfWork: ICalendarUnitOfWork,
86
+ input: {
87
+ accountConfigId: string;
88
+ calendarId: string;
89
+ calendarObjectId: string;
90
+ },
91
+ ): Promise<void> => {
92
+ await unitOfWork.transaction(async (repos) => {
93
+ await repos.calendarEventIndex.deleteForObject(
94
+ input.calendarId,
95
+ input.calendarObjectId,
96
+ );
97
+ await repos.calendarObject.delete(input.calendarId, input.calendarObjectId);
98
+ await repos.calendarCollection.bumpSyncSequence(
99
+ input.accountConfigId,
100
+ input.calendarId,
101
+ );
102
+ });
103
+ };
104
+
105
+ /**
106
+ * The collection an account config stores events in until someone makes
107
+ * another. Safe to call on every request that needs a calendar: `calendarId` is
108
+ * derived from the account config and the URL segment, so a second call returns
109
+ * the collection the first one made rather than a rival copy of it.
110
+ */
111
+ export const provisionDefaultCalendar = (
112
+ unitOfWork: ICalendarUnitOfWork,
113
+ accountConfigId: string,
114
+ displayName = "Calendar",
115
+ ): Promise<CalendarCollectionItem> =>
116
+ unitOfWork.transaction((repos) =>
117
+ repos.calendarCollection.create({
118
+ accountConfigId,
119
+ urlSegment: DEFAULT_CALENDAR_URL_SEGMENT,
120
+ displayName,
121
+ source: CalendarSource.Default,
122
+ }),
123
+ );