@remit/calendar-service 0.0.11 → 0.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/errors.ts CHANGED
@@ -35,7 +35,9 @@ export type CalendarValidationCode =
35
35
  | "NotRecurring"
36
36
  /** A RECURRENCE-ID naming no occurrence this series produces. */
37
37
  | "UnknownOccurrence"
38
- | "UnmovableRecurrenceRule";
38
+ | "UnmovableRecurrenceRule"
39
+ /** A subscription address that is not an http, https or webcal URL. */
40
+ | "InvalidSubscriptionUrl";
39
41
 
40
42
  export interface CalendarValidationError {
41
43
  code: CalendarValidationCode;
@@ -0,0 +1,99 @@
1
+ BEGIN:VCALENDAR
2
+ PRODID:-//Google Inc//Google Calendar 70.9054//EN
3
+ VERSION:2.0
4
+ CALSCALE:GREGORIAN
5
+ METHOD:PUBLISH
6
+ X-WR-CALNAME:Harbour rota
7
+ X-WR-TIMEZONE:Europe/Amsterdam
8
+ BEGIN:VTIMEZONE
9
+ TZID:Europe/Amsterdam
10
+ X-LIC-LOCATION:Europe/Amsterdam
11
+ BEGIN:DAYLIGHT
12
+ TZOFFSETFROM:+0100
13
+ TZOFFSETTO:+0200
14
+ TZNAME:CEST
15
+ DTSTART:19700329T020000
16
+ RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
17
+ END:DAYLIGHT
18
+ BEGIN:STANDARD
19
+ TZOFFSETFROM:+0200
20
+ TZOFFSETTO:+0100
21
+ TZNAME:CET
22
+ DTSTART:19701025T030000
23
+ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
24
+ END:STANDARD
25
+ END:VTIMEZONE
26
+ BEGIN:VTIMEZONE
27
+ TZID:America/New_York
28
+ BEGIN:DAYLIGHT
29
+ TZOFFSETFROM:-0500
30
+ TZOFFSETTO:-0400
31
+ TZNAME:EDT
32
+ DTSTART:19700308T020000
33
+ RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
34
+ END:DAYLIGHT
35
+ BEGIN:STANDARD
36
+ TZOFFSETFROM:-0400
37
+ TZOFFSETTO:-0500
38
+ TZNAME:EST
39
+ DTSTART:19701101T020000
40
+ RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
41
+ END:STANDARD
42
+ END:VTIMEZONE
43
+ BEGIN:VEVENT
44
+ DTSTART;TZID=Europe/Amsterdam:20261005T090000
45
+ DTEND;TZID=Europe/Amsterdam:20261005T093000
46
+ RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=MO
47
+ EXDATE;TZID=Europe/Amsterdam:20261019T090000
48
+ DTSTAMP:20260925T120000Z
49
+ UID:pilot-standup@google.com
50
+ CREATED:20260901T080000Z
51
+ LAST-MODIFIED:20260920T080000Z
52
+ SEQUENCE:0
53
+ STATUS:CONFIRMED
54
+ SUMMARY:Pilot standup
55
+ TRANSP:OPAQUE
56
+ END:VEVENT
57
+ BEGIN:VEVENT
58
+ DTSTART;TZID=Europe/Amsterdam:20261012T100000
59
+ DTEND;TZID=Europe/Amsterdam:20261012T103000
60
+ DTSTAMP:20260925T120000Z
61
+ UID:pilot-standup@google.com
62
+ RECURRENCE-ID;TZID=Europe/Amsterdam:20261012T090000
63
+ SEQUENCE:1
64
+ STATUS:CONFIRMED
65
+ SUMMARY:Pilot standup (moved)
66
+ TRANSP:OPAQUE
67
+ END:VEVENT
68
+ BEGIN:VEVENT
69
+ DTSTART;TZID=Europe/Amsterdam:20261102T090000
70
+ DTEND;TZID=Europe/Amsterdam:20261102T093000
71
+ DTSTAMP:20260925T120000Z
72
+ UID:pilot-standup@google.com
73
+ RECURRENCE-ID;TZID=Europe/Amsterdam:20261102T090000
74
+ SEQUENCE:1
75
+ STATUS:CANCELLED
76
+ SUMMARY:Pilot standup
77
+ TRANSP:OPAQUE
78
+ END:VEVENT
79
+ BEGIN:VEVENT
80
+ DTSTART;VALUE=DATE:20261007
81
+ DTEND;VALUE=DATE:20261009
82
+ DTSTAMP:20260925T120000Z
83
+ UID:quay-offsite@google.com
84
+ SEQUENCE:0
85
+ STATUS:CONFIRMED
86
+ SUMMARY:Quay offsite
87
+ TRANSP:TRANSPARENT
88
+ END:VEVENT
89
+ BEGIN:VEVENT
90
+ DTSTART:20261008T140000Z
91
+ DTEND:20261008T150000Z
92
+ DTSTAMP:20260925T120000Z
93
+ UID:tide-review@google.com
94
+ SEQUENCE:0
95
+ STATUS:CONFIRMED
96
+ SUMMARY:Tide table review
97
+ TRANSP:OPAQUE
98
+ END:VEVENT
99
+ END:VCALENDAR
package/src/index.ts CHANGED
@@ -67,6 +67,25 @@ export {
67
67
  type ScopedWrite,
68
68
  type ScopedWriteInput,
69
69
  } from "./scope.js";
70
+ export {
71
+ CALENDAR_SUBSCRIPTION_MAX_BYTES,
72
+ CALENDAR_SUBSCRIPTION_TIMEOUT_MS,
73
+ type CalendarFeedFetch,
74
+ type CalendarFeedFetcher,
75
+ type CalendarFeedResource,
76
+ type CalendarFeedSplit,
77
+ type CalendarFeedWrite,
78
+ type CalendarSubscriptionRefresh,
79
+ type CalendarSubscriptionTarget,
80
+ fetchCalendarFeed,
81
+ isSubscriptionDue,
82
+ readCalendarFeed,
83
+ readSubscriptionUrl,
84
+ recordCalendarFeedFailure,
85
+ refreshCalendarSubscription,
86
+ splitCalendarFeed,
87
+ writeCalendarFeed,
88
+ } from "./subscribe.js";
70
89
  export {
71
90
  type CalendarSuggestionProjection,
72
91
  inviteMethodOf,
@@ -65,6 +65,11 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
65
65
  source: input.source ?? "UserCreated",
66
66
  timezone: input.timezone ?? "",
67
67
  syncSequence: 0,
68
+ subscriptionUrl: input.subscriptionUrl ?? "",
69
+ subscriptionEnabled: input.subscriptionEnabled ?? false,
70
+ subscriptionCheckedAt: 0,
71
+ subscriptionFetchedAt: 0,
72
+ subscriptionError: "",
68
73
  createdAt: now,
69
74
  updatedAt: now,
70
75
  };
@@ -102,6 +107,11 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
102
107
  if (this.collections.has(calendarId)) return null;
103
108
  return this.collectionRepo.create(input);
104
109
  },
110
+ listEnabledSubscriptions: async () =>
111
+ [...this.collections.values()].filter(
112
+ (collection) =>
113
+ collection.source === "Subscribed" && collection.subscriptionEnabled,
114
+ ),
105
115
  findByUrlSegment: async (accountConfigId: string, urlSegment: string) =>
106
116
  this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
107
117
  null,
package/src/parse.ts CHANGED
@@ -35,7 +35,7 @@ export const serializeCalendar = (component: ICAL.Component): string =>
35
35
  * ical.js's parser in a promise, so unreadable bytes arrive as a rejection to
36
36
  * branch on rather than a synchronous throw.
37
37
  */
38
- const readComponent = (
38
+ export const readCalendarComponent = (
39
39
  icalData: string,
40
40
  ): Promise<CalendarResult<ICAL.Component>> =>
41
41
  new Promise<ICAL.Component>((resolve) => {
@@ -59,7 +59,7 @@ const readComponent = (
59
59
  export const parseCalendar = async (
60
60
  icalData: string,
61
61
  ): Promise<CalendarResult<ParsedCalendar>> => {
62
- const read = await readComponent(icalData);
62
+ const read = await readCalendarComponent(icalData);
63
63
  if (!read.ok) return read;
64
64
  const component = read.value;
65
65
 
@@ -0,0 +1,578 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { describe, it } from "node:test";
4
+ import type { CalendarCollectionItem } from "@remit/data-ports";
5
+ import { CalendarSource } from "@remit/domain-enums";
6
+ import { ical } from "./fixtures.js";
7
+ import { MemoryCalendarStore } from "./memory-store.js";
8
+ import { parseCalendar } from "./parse.js";
9
+ import {
10
+ type CalendarFeedFetcher,
11
+ fetchCalendarFeed,
12
+ isSubscriptionDue,
13
+ readSubscriptionUrl,
14
+ refreshCalendarSubscription,
15
+ splitCalendarFeed,
16
+ writeCalendarFeed,
17
+ } from "./subscribe.js";
18
+ import { listCalendarInstances } from "./window.js";
19
+
20
+ const ACCOUNT_CONFIG_ID = "account-config-1";
21
+ const SECRET_URL =
22
+ "https://calendar.example/calendar/ical/harbour%40example.com/private-5f1c0ffee/basic.ics";
23
+ const GOOGLE_FEED = readFileSync(
24
+ new URL("./feeds/google-secret-address.ics", import.meta.url),
25
+ "utf8",
26
+ );
27
+ const WINDOW = {
28
+ from: "2026-10-01T00:00:00Z",
29
+ to: "2026-11-16T00:00:00Z",
30
+ };
31
+
32
+ const answering =
33
+ (body: string, init: ResponseInit = {}): CalendarFeedFetcher =>
34
+ async () =>
35
+ new Response(body, init);
36
+
37
+ const subscribed = async (): Promise<{
38
+ store: MemoryCalendarStore;
39
+ collection: CalendarCollectionItem;
40
+ }> => {
41
+ const store = new MemoryCalendarStore();
42
+ const collection = await store.transaction((repos) =>
43
+ repos.calendarCollection.create({
44
+ accountConfigId: ACCOUNT_CONFIG_ID,
45
+ urlSegment: "harbour-rota",
46
+ displayName: "Harbour rota",
47
+ source: CalendarSource.Subscribed,
48
+ subscriptionUrl: SECRET_URL,
49
+ subscriptionEnabled: true,
50
+ }),
51
+ );
52
+ return { store, collection };
53
+ };
54
+
55
+ const drawn = async (
56
+ store: MemoryCalendarStore,
57
+ collection: CalendarCollectionItem,
58
+ ) => {
59
+ const current = await store.transaction((repos) =>
60
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
61
+ );
62
+ const instances = await store.transaction((repos) =>
63
+ listCalendarInstances(repos, [current], WINDOW),
64
+ );
65
+ return instances.map((instance) => ({
66
+ summary: instance.summary,
67
+ startAt: instance.startAt,
68
+ allDay: instance.allDay,
69
+ status: instance.status,
70
+ }));
71
+ };
72
+
73
+ const writeFeed = async (
74
+ store: MemoryCalendarStore,
75
+ collection: CalendarCollectionItem,
76
+ feed: string,
77
+ now: number,
78
+ ) => {
79
+ const split = await splitCalendarFeed(feed);
80
+ assert.ok(split.ok);
81
+ return writeCalendarFeed(
82
+ store,
83
+ {
84
+ accountConfigId: ACCOUNT_CONFIG_ID,
85
+ calendarId: collection.calendarId,
86
+ },
87
+ split.value,
88
+ now,
89
+ );
90
+ };
91
+
92
+ describe("readSubscriptionUrl", () => {
93
+ it("reads webcal as the same address over https", () => {
94
+ const url = readSubscriptionUrl(
95
+ " webcal://calendar.example/feeds/rota.ics ",
96
+ );
97
+ assert.deepEqual(url, {
98
+ ok: true,
99
+ value: "https://calendar.example/feeds/rota.ics",
100
+ });
101
+ });
102
+
103
+ it("keeps http and https as given", () => {
104
+ assert.deepEqual(readSubscriptionUrl("http://127.0.0.1:8080/a.ics"), {
105
+ ok: true,
106
+ value: "http://127.0.0.1:8080/a.ics",
107
+ });
108
+ assert.deepEqual(readSubscriptionUrl(SECRET_URL), {
109
+ ok: true,
110
+ value: SECRET_URL,
111
+ });
112
+ });
113
+
114
+ it("refuses what is not a web address", () => {
115
+ for (const raw of [
116
+ "",
117
+ "calendar.example/rota.ics",
118
+ "ftp://calendar.example/rota.ics",
119
+ "file:///etc/passwd",
120
+ "https://user:secret@calendar.example/rota.ics",
121
+ ]) {
122
+ const url = readSubscriptionUrl(raw);
123
+ assert.equal(url.ok, false, raw);
124
+ if (!url.ok) assert.equal(url.error.code, "InvalidSubscriptionUrl");
125
+ }
126
+ });
127
+ });
128
+
129
+ describe("fetchCalendarFeed", () => {
130
+ it("answers the feed's text", async () => {
131
+ const fetched = await fetchCalendarFeed(
132
+ SECRET_URL,
133
+ answering(GOOGLE_FEED, { status: 200 }),
134
+ );
135
+ assert.deepEqual(fetched, { ok: true, value: GOOGLE_FEED });
136
+ });
137
+
138
+ it("names the status of a refusal and never the address", async () => {
139
+ const fetched = await fetchCalendarFeed(
140
+ SECRET_URL,
141
+ answering("gone", { status: 404 }),
142
+ );
143
+ assert.deepEqual(fetched, {
144
+ ok: false,
145
+ reason: "the feed answered HTTP 404",
146
+ });
147
+ });
148
+
149
+ it("names why an address could not be reached, without the address", async () => {
150
+ const fetched = await fetchCalendarFeed(SECRET_URL, async (url) => {
151
+ throw new TypeError("fetch failed", {
152
+ cause: Object.assign(new Error(`connect ECONNREFUSED ${url}`), {
153
+ code: "ECONNREFUSED",
154
+ }),
155
+ });
156
+ });
157
+ assert.deepEqual(fetched, {
158
+ ok: false,
159
+ reason: "the feed could not be reached (ECONNREFUSED)",
160
+ });
161
+ });
162
+
163
+ it("says so when the feed does not answer in time", async () => {
164
+ const fetched = await fetchCalendarFeed(SECRET_URL, async () => {
165
+ throw new DOMException("timed out", "TimeoutError");
166
+ });
167
+ assert.deepEqual(fetched, {
168
+ ok: false,
169
+ reason: "the feed did not answer within 30 seconds",
170
+ });
171
+ });
172
+
173
+ it("stops reading a chunked feed once it passes the cap", async () => {
174
+ let pulled = 0;
175
+ let cancelled = false;
176
+ const chunk = new Uint8Array(1024 * 1024).fill(0x41);
177
+ const endless = new ReadableStream<Uint8Array>({
178
+ pull(controller) {
179
+ pulled += 1;
180
+ controller.enqueue(chunk);
181
+ },
182
+ cancel() {
183
+ cancelled = true;
184
+ },
185
+ });
186
+
187
+ const fetched = await fetchCalendarFeed(SECRET_URL, async () => {
188
+ const response = new Response(endless, { status: 200 });
189
+ assert.equal(response.headers.get("content-length"), null);
190
+ return response;
191
+ });
192
+
193
+ assert.deepEqual(fetched, {
194
+ ok: false,
195
+ reason: "the feed is larger than 10 MB",
196
+ });
197
+ assert.equal(cancelled, true);
198
+ assert.ok(pulled <= 12, `read ${pulled} MB of a feed capped at 10`);
199
+ });
200
+
201
+ it("refuses a feed larger than it will store", async () => {
202
+ const fetched = await fetchCalendarFeed(
203
+ SECRET_URL,
204
+ answering("x", {
205
+ status: 200,
206
+ headers: { "content-length": String(64 * 1024 * 1024) },
207
+ }),
208
+ );
209
+ assert.deepEqual(fetched, {
210
+ ok: false,
211
+ reason: "the feed is larger than 10 MB",
212
+ });
213
+ });
214
+ });
215
+
216
+ describe("splitCalendarFeed", () => {
217
+ it("cuts a feed into one resource per UID, each one the write path accepts", async () => {
218
+ const split = await splitCalendarFeed(GOOGLE_FEED);
219
+ assert.ok(split.ok);
220
+ assert.equal(split.value.unreadable, 0);
221
+ assert.deepEqual(
222
+ split.value.resources.map((resource) => resource.uid),
223
+ [
224
+ "pilot-standup@google.com",
225
+ "quay-offsite@google.com",
226
+ "tide-review@google.com",
227
+ ],
228
+ );
229
+
230
+ for (const resource of split.value.resources) {
231
+ assert.match(resource.resourceName, /^[0-9a-f]{40}\.ics$/);
232
+ const parsed = await parseCalendar(resource.icalData);
233
+ assert.ok(parsed.ok, resource.uid);
234
+ assert.equal(parsed.value.uid, resource.uid);
235
+ }
236
+ });
237
+
238
+ it("keeps a series with its overrides and only the zones it names", async () => {
239
+ const split = await splitCalendarFeed(GOOGLE_FEED);
240
+ assert.ok(split.ok);
241
+ const standup = split.value.resources[0];
242
+ assert.ok(standup);
243
+
244
+ const parsed = await parseCalendar(standup.icalData);
245
+ assert.ok(parsed.ok);
246
+ assert.equal(parsed.value.overrides.length, 2);
247
+ assert.deepEqual(
248
+ parsed.value.component
249
+ .getAllSubcomponents("vtimezone")
250
+ .map((zone) => zone.getFirstPropertyValue("tzid")),
251
+ ["Europe/Amsterdam"],
252
+ );
253
+ assert.equal(parsed.value.component.hasProperty("method"), false);
254
+ assert.equal(
255
+ parsed.value.component.getFirstPropertyValue("x-wr-calname"),
256
+ "Harbour rota",
257
+ );
258
+
259
+ const offsite = split.value.resources[1];
260
+ assert.ok(offsite);
261
+ assert.doesNotMatch(offsite.icalData, /BEGIN:VTIMEZONE/);
262
+ });
263
+
264
+ it("names the same resource on every read of the same feed", async () => {
265
+ const first = await splitCalendarFeed(GOOGLE_FEED);
266
+ const second = await splitCalendarFeed(GOOGLE_FEED);
267
+ assert.ok(first.ok && second.ok);
268
+ assert.deepEqual(first.value.resources, second.value.resources);
269
+ });
270
+
271
+ it("counts an event with no UID instead of storing it", async () => {
272
+ const split = await splitCalendarFeed(
273
+ ical(
274
+ "BEGIN:VCALENDAR",
275
+ "VERSION:2.0",
276
+ "PRODID:-//Example//Feed//EN",
277
+ "BEGIN:VEVENT",
278
+ "DTSTART:20261008T140000Z",
279
+ "SUMMARY:Nobody's event",
280
+ "END:VEVENT",
281
+ "END:VCALENDAR",
282
+ ),
283
+ );
284
+ assert.ok(split.ok);
285
+ assert.equal(split.value.resources.length, 0);
286
+ assert.equal(split.value.unreadable, 1);
287
+ });
288
+
289
+ it("refuses bytes that are not a calendar", async () => {
290
+ const split = await splitCalendarFeed(
291
+ "<!doctype html><title>Sign in</title>",
292
+ );
293
+ assert.equal(split.ok, false);
294
+ });
295
+ });
296
+
297
+ describe("writeCalendarFeed", () => {
298
+ it("keeps recurrence, a moved and a cancelled instance, all-day and zoned times", async () => {
299
+ const { store, collection } = await subscribed();
300
+
301
+ const write = await writeFeed(store, collection, GOOGLE_FEED, 5_000);
302
+
303
+ assert.deepEqual(write, {
304
+ paused: false,
305
+ written: 3,
306
+ unchanged: 0,
307
+ removed: 0,
308
+ skipped: 0,
309
+ });
310
+ assert.deepEqual(await drawn(store, collection), [
311
+ {
312
+ summary: "Pilot standup",
313
+ startAt: "2026-10-05T07:00:00Z",
314
+ allDay: false,
315
+ status: "Confirmed",
316
+ },
317
+ {
318
+ summary: "Quay offsite",
319
+ startAt: "2026-10-07T00:00:00Z",
320
+ allDay: true,
321
+ status: "Confirmed",
322
+ },
323
+ {
324
+ summary: "Tide table review",
325
+ startAt: "2026-10-08T14:00:00Z",
326
+ allDay: false,
327
+ status: "Confirmed",
328
+ },
329
+ {
330
+ summary: "Pilot standup (moved)",
331
+ startAt: "2026-10-12T08:00:00Z",
332
+ allDay: false,
333
+ status: "Confirmed",
334
+ },
335
+ {
336
+ summary: "Pilot standup",
337
+ startAt: "2026-10-26T08:00:00Z",
338
+ allDay: false,
339
+ status: "Confirmed",
340
+ },
341
+ {
342
+ summary: "Pilot standup",
343
+ startAt: "2026-11-02T08:00:00Z",
344
+ allDay: false,
345
+ status: "Cancelled",
346
+ },
347
+ {
348
+ summary: "Pilot standup",
349
+ startAt: "2026-11-09T08:00:00Z",
350
+ allDay: false,
351
+ status: "Confirmed",
352
+ },
353
+ ]);
354
+
355
+ const stored = await store.transaction((repos) =>
356
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
357
+ );
358
+ assert.equal(stored.subscriptionCheckedAt, 5_000);
359
+ assert.equal(stored.subscriptionFetchedAt, 5_000);
360
+ assert.equal(stored.subscriptionError, "");
361
+ });
362
+
363
+ it("leaves unchanged events alone and removes what the feed dropped", async () => {
364
+ const { store, collection } = await subscribed();
365
+ await writeFeed(store, collection, GOOGLE_FEED, 5_000);
366
+ const sequenceBefore = (
367
+ await store.transaction((repos) =>
368
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
369
+ )
370
+ ).syncSequence;
371
+
372
+ const dropped = GOOGLE_FEED.replace(
373
+ /BEGIN:VEVENT\r\nDTSTART:20261008T140000Z[\s\S]*?END:VEVENT\r\n/,
374
+ "",
375
+ );
376
+ const write = await writeFeed(store, collection, dropped, 9_000);
377
+
378
+ assert.deepEqual(write, {
379
+ paused: false,
380
+ written: 0,
381
+ unchanged: 2,
382
+ removed: 1,
383
+ skipped: 0,
384
+ });
385
+ const summaries = (await drawn(store, collection)).map(
386
+ (instance) => instance.summary,
387
+ );
388
+ assert.equal(summaries.includes("Tide table review"), false);
389
+ assert.equal(summaries.length, 6);
390
+ const after = await store.transaction((repos) =>
391
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
392
+ );
393
+ assert.equal(after.syncSequence, sequenceBefore + 1);
394
+ });
395
+
396
+ it("writes nothing when a second fetch differs only in its export stamps", async () => {
397
+ const { store, collection } = await subscribed();
398
+ await writeFeed(store, collection, GOOGLE_FEED, 5_000);
399
+ const before = await store.transaction((repos) =>
400
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
401
+ );
402
+
403
+ const restamped = GOOGLE_FEED.replaceAll(
404
+ "DTSTAMP:20260925T120000Z",
405
+ "DTSTAMP:20260926T081500Z",
406
+ );
407
+ const write = await writeFeed(store, collection, restamped, 9_000);
408
+
409
+ assert.deepEqual(write, {
410
+ paused: false,
411
+ written: 0,
412
+ unchanged: 3,
413
+ removed: 0,
414
+ skipped: 0,
415
+ });
416
+ const after = await store.transaction((repos) =>
417
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
418
+ );
419
+ assert.equal(after.syncSequence, before.syncSequence);
420
+ assert.equal(after.subscriptionFetchedAt, 9_000);
421
+ });
422
+
423
+ it("writes nothing for a subscription paused while its feed was read", async () => {
424
+ const { store, collection } = await subscribed();
425
+ await store.transaction((repos) =>
426
+ repos.calendarCollection.update(
427
+ ACCOUNT_CONFIG_ID,
428
+ collection.calendarId,
429
+ { subscriptionEnabled: false },
430
+ ),
431
+ );
432
+
433
+ const write = await writeFeed(store, collection, GOOGLE_FEED, 5_000);
434
+
435
+ assert.deepEqual(write, {
436
+ paused: true,
437
+ written: 0,
438
+ unchanged: 0,
439
+ removed: 0,
440
+ skipped: 0,
441
+ });
442
+ assert.deepEqual(await drawn(store, collection), []);
443
+ const stored = await store.transaction((repos) =>
444
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
445
+ );
446
+ assert.equal(stored.subscriptionFetchedAt, 0);
447
+ });
448
+
449
+ it("records the events it could not store and keeps the rest", async () => {
450
+ const { store, collection } = await subscribed();
451
+
452
+ const write = await writeFeed(
453
+ store,
454
+ collection,
455
+ ical(
456
+ "BEGIN:VCALENDAR",
457
+ "VERSION:2.0",
458
+ "PRODID:-//Example//Feed//EN",
459
+ "BEGIN:VEVENT",
460
+ "UID:kept@example.com",
461
+ "DTSTART:20261008T140000Z",
462
+ "DTEND:20261008T150000Z",
463
+ "SUMMARY:Kept",
464
+ "END:VEVENT",
465
+ "BEGIN:VEVENT",
466
+ "UID:backwards@example.com",
467
+ "DTSTART:20261008T140000Z",
468
+ "DTEND:20261008T130000Z",
469
+ "SUMMARY:Backwards",
470
+ "END:VEVENT",
471
+ "BEGIN:VEVENT",
472
+ "DTSTART:20261009T140000Z",
473
+ "SUMMARY:No UID",
474
+ "END:VEVENT",
475
+ "END:VCALENDAR",
476
+ ),
477
+ 7_000,
478
+ );
479
+
480
+ assert.equal(write.written, 1);
481
+ assert.equal(write.skipped, 2);
482
+ const stored = await store.transaction((repos) =>
483
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
484
+ );
485
+ assert.equal(
486
+ stored.subscriptionError,
487
+ "2 events in the feed could not be stored",
488
+ );
489
+ assert.deepEqual(
490
+ (await drawn(store, collection)).map((instance) => instance.summary),
491
+ ["Kept"],
492
+ );
493
+ });
494
+ });
495
+
496
+ describe("refreshCalendarSubscription", () => {
497
+ it("records a failed fetch on the collection and keeps every event", async () => {
498
+ const { store, collection } = await subscribed();
499
+ await writeFeed(store, collection, GOOGLE_FEED, 5_000);
500
+
501
+ const refreshed = await refreshCalendarSubscription(
502
+ store,
503
+ collection,
504
+ 9_000,
505
+ answering("unavailable", { status: 503 }),
506
+ );
507
+
508
+ assert.deepEqual(refreshed, {
509
+ ok: false,
510
+ reason: "the feed answered HTTP 503",
511
+ });
512
+ const stored = await store.transaction((repos) =>
513
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
514
+ );
515
+ assert.equal(stored.subscriptionCheckedAt, 9_000);
516
+ assert.equal(stored.subscriptionFetchedAt, 5_000);
517
+ assert.equal(stored.subscriptionError, "the feed answered HTTP 503");
518
+ assert.equal(stored.subscriptionError.includes(SECRET_URL), false);
519
+ assert.equal((await drawn(store, collection)).length, 7);
520
+ });
521
+
522
+ it("states that a page which is not a calendar is not one", async () => {
523
+ const { store, collection } = await subscribed();
524
+
525
+ const refreshed = await refreshCalendarSubscription(
526
+ store,
527
+ collection,
528
+ 9_000,
529
+ answering("<!doctype html><title>Sign in</title>", { status: 200 }),
530
+ );
531
+
532
+ assert.equal(refreshed.ok, false);
533
+ if (!refreshed.ok) {
534
+ assert.match(refreshed.reason, /^the feed is not a calendar: /);
535
+ }
536
+ });
537
+
538
+ it("fetches the collection's own address and clears an earlier failure", async () => {
539
+ const { store, collection } = await subscribed();
540
+ await refreshCalendarSubscription(
541
+ store,
542
+ collection,
543
+ 3_000,
544
+ answering("", { status: 500 }),
545
+ );
546
+ const asked: string[] = [];
547
+
548
+ const refreshed = await refreshCalendarSubscription(
549
+ store,
550
+ collection,
551
+ 6_000,
552
+ async (url) => {
553
+ asked.push(url);
554
+ return new Response(GOOGLE_FEED, { status: 200 });
555
+ },
556
+ );
557
+
558
+ assert.deepEqual(asked, [SECRET_URL]);
559
+ assert.ok(refreshed.ok);
560
+ const stored = await store.transaction((repos) =>
561
+ repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
562
+ );
563
+ assert.equal(stored.subscriptionError, "");
564
+ assert.equal(stored.subscriptionFetchedAt, 6_000);
565
+ });
566
+ });
567
+
568
+ describe("isSubscriptionDue", () => {
569
+ it("is due once the last fetch is an interval old, failed or not", async () => {
570
+ const { collection } = await subscribed();
571
+ const checked = { ...collection, subscriptionCheckedAt: 10_000 };
572
+
573
+ assert.equal(isSubscriptionDue(checked, 10_000 + 899, 900), false);
574
+ assert.equal(isSubscriptionDue(checked, 10_000 + 900, 900), true);
575
+ assert.equal(isSubscriptionDue(collection, 1, 900), false);
576
+ assert.equal(isSubscriptionDue(collection, 900, 900), true);
577
+ });
578
+ });
@@ -0,0 +1,420 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ CalendarCollectionItem,
4
+ ICalendarUnitOfWork,
5
+ } from "@remit/data-ports";
6
+ import ICAL from "ical.js";
7
+ import { type CalendarResult, calendarFailure } from "./errors.js";
8
+ import { readCalendarComponent, serializeCalendar } from "./parse.js";
9
+ import { deleteCalendarObject, putCalendarObject } from "./put.js";
10
+
11
+ export const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30_000;
12
+
13
+ export const CALENDAR_SUBSCRIPTION_MAX_BYTES = 10 * 1024 * 1024;
14
+
15
+ const SUBSCRIPTION_ERROR_MAX_LENGTH = 512;
16
+
17
+ const SCHEMES: Record<string, string> = {
18
+ "http:": "http:",
19
+ "https:": "https:",
20
+ "webcal:": "https:",
21
+ "webcals:": "https:",
22
+ };
23
+
24
+ export type CalendarFeedFetcher = (
25
+ url: string,
26
+ init: RequestInit,
27
+ ) => Promise<Response>;
28
+
29
+ export type CalendarFeedFetch =
30
+ | { ok: true; value: string }
31
+ | { ok: false; reason: string };
32
+
33
+ export interface CalendarFeedResource {
34
+ uid: string;
35
+ resourceName: string;
36
+ icalData: string;
37
+ }
38
+
39
+ export interface CalendarFeedSplit {
40
+ resources: CalendarFeedResource[];
41
+ unreadable: number;
42
+ }
43
+
44
+ export interface CalendarFeedWrite {
45
+ /** The subscription was paused while its feed was being read, so nothing was written. */
46
+ paused: boolean;
47
+ written: number;
48
+ unchanged: number;
49
+ removed: number;
50
+ skipped: number;
51
+ }
52
+
53
+ export interface CalendarSubscriptionTarget {
54
+ accountConfigId: string;
55
+ calendarId: string;
56
+ }
57
+
58
+ export type CalendarSubscriptionRefresh =
59
+ | { ok: true; value: CalendarFeedWrite }
60
+ | { ok: false; reason: string };
61
+
62
+ /**
63
+ * The URL a subscription is fetched from. `webcal:` is what calendar products
64
+ * hand out for "subscribe", and means the same address over HTTPS.
65
+ */
66
+ export const readSubscriptionUrl = (raw: string): CalendarResult<string> => {
67
+ const trimmed = raw.trim();
68
+ if (!URL.canParse(trimmed)) {
69
+ return calendarFailure(
70
+ "InvalidSubscriptionUrl",
71
+ "the subscription address is not a URL",
72
+ );
73
+ }
74
+ const url = new URL(trimmed);
75
+ const scheme = SCHEMES[url.protocol];
76
+ if (!scheme) {
77
+ return calendarFailure(
78
+ "InvalidSubscriptionUrl",
79
+ `a subscription address starts with http, https or webcal, not ${url.protocol.replace(/:$/, "")}`,
80
+ );
81
+ }
82
+ if (url.username !== "" || url.password !== "") {
83
+ return calendarFailure(
84
+ "InvalidSubscriptionUrl",
85
+ "a subscription address carries no user name or password",
86
+ );
87
+ }
88
+ const address = new URL(`${scheme}${trimmed.slice(url.protocol.length)}`);
89
+ return { ok: true, value: address.toString() };
90
+ };
91
+
92
+ const truncate = (reason: string): string =>
93
+ reason.length <= SUBSCRIPTION_ERROR_MAX_LENGTH
94
+ ? reason
95
+ : `${reason.slice(0, SUBSCRIPTION_ERROR_MAX_LENGTH - 1)}…`;
96
+
97
+ const unreachableReason = (error: unknown): string => {
98
+ if (error instanceof Error && error.name === "TimeoutError") {
99
+ return `the feed did not answer within ${CALENDAR_SUBSCRIPTION_TIMEOUT_MS / 1000} seconds`;
100
+ }
101
+ const cause =
102
+ error instanceof Error && error.cause instanceof Error
103
+ ? error.cause
104
+ : undefined;
105
+ const code =
106
+ cause && "code" in cause && typeof cause.code === "string"
107
+ ? cause.code
108
+ : undefined;
109
+ return code
110
+ ? `the feed could not be reached (${code})`
111
+ : "the feed could not be reached";
112
+ };
113
+
114
+ /**
115
+ * Reads a subscription's feed. Every failure is a returned reason that a
116
+ * person can act on and that never repeats the URL: for a Google secret
117
+ * address the URL is the whole credential, and the reason is stored on the
118
+ * collection and drawn in the calendar view.
119
+ */
120
+ export const fetchCalendarFeed = async (
121
+ url: string,
122
+ fetcher: CalendarFeedFetcher = fetch,
123
+ ): Promise<CalendarFeedFetch> => {
124
+ const response = await fetcher(url, {
125
+ headers: { accept: "text/calendar, */*;q=0.5" },
126
+ redirect: "follow",
127
+ signal: AbortSignal.timeout(CALENDAR_SUBSCRIPTION_TIMEOUT_MS),
128
+ }).then(
129
+ (value) => ({ ok: true, value }) as const,
130
+ (error: unknown) =>
131
+ ({ ok: false, reason: unreachableReason(error) }) as const,
132
+ );
133
+ if (!response.ok) return response;
134
+
135
+ const { status } = response.value;
136
+ if (status < 200 || status > 299) {
137
+ await response.value.body?.cancel();
138
+ return { ok: false, reason: `the feed answered HTTP ${status}` };
139
+ }
140
+
141
+ const declared = Number(response.value.headers.get("content-length") ?? "0");
142
+ if (declared > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
143
+ await response.value.body?.cancel();
144
+ return { ok: false, reason: TOO_LARGE };
145
+ }
146
+
147
+ const { body } = response.value;
148
+ if (!body) return { ok: true, value: "" };
149
+ return readCapped(body).then(
150
+ (read) => read,
151
+ (error: unknown) => ({ ok: false, reason: unreachableReason(error) }),
152
+ );
153
+ };
154
+
155
+ const TOO_LARGE = "the feed is larger than 10 MB";
156
+
157
+ /**
158
+ * Reads a body a chunk at a time and stops at the cap. A feed sent chunked
159
+ * carries no length to refuse up front, so counting as it arrives is the only
160
+ * thing that keeps an endless body out of memory.
161
+ */
162
+ const readCapped = async (
163
+ body: ReadableStream<Uint8Array>,
164
+ ): Promise<CalendarFeedFetch> => {
165
+ const reader = body.getReader();
166
+ const decoder = new TextDecoder();
167
+ let received = 0;
168
+ let text = "";
169
+ for (;;) {
170
+ const { done, value } = await reader.read();
171
+ if (done) break;
172
+ received += value.byteLength;
173
+ if (received > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
174
+ await reader.cancel();
175
+ return { ok: false, reason: TOO_LARGE };
176
+ }
177
+ text += decoder.decode(value, { stream: true });
178
+ }
179
+ return { ok: true, value: text + decoder.decode() };
180
+ };
181
+
182
+ /**
183
+ * A resource as it would compare across two fetches. DTSTAMP is when the
184
+ * provider exported the feed, not when the event changed — Google restamps it
185
+ * on every fetch — so it is left out, or every refresh would rewrite every
186
+ * event.
187
+ */
188
+ const comparable = (icalData: string): string =>
189
+ icalData.replace(/^DTSTAMP[;:][^\r\n]*\r?\n/gim, "");
190
+
191
+ const resourceNameOf = (uid: string): string =>
192
+ `${createHash("sha256").update(uid, "utf8").digest("hex").slice(0, 40)}.ics`;
193
+
194
+ const zonesReferencedBy = (events: ICAL.Component[]): Set<string> => {
195
+ const zones = new Set<string>();
196
+ for (const event of events) {
197
+ for (const property of event.getAllProperties()) {
198
+ const tzid = property.getParameter("tzid");
199
+ if (typeof tzid === "string") zones.add(tzid);
200
+ }
201
+ }
202
+ return zones;
203
+ };
204
+
205
+ /**
206
+ * Cuts a whole feed into one resource per UID, the shape the calendar write
207
+ * path stores: a series and its RECURRENCE-ID overrides travel together, with
208
+ * the VTIMEZONEs they name, so a DST-crossing series resolves against the zone
209
+ * the provider published.
210
+ *
211
+ * The feed's calendar-level properties are kept on every resource except
212
+ * METHOD, which a stored resource never carries. A VEVENT with no UID has
213
+ * nothing to key it by across refreshes, so it is counted and left out.
214
+ */
215
+ export const splitCalendarFeed = async (
216
+ icalData: string,
217
+ ): Promise<CalendarResult<CalendarFeedSplit>> => {
218
+ const read = await readCalendarComponent(icalData);
219
+ if (!read.ok) return read;
220
+ const feed = read.value;
221
+ if (feed.name !== "vcalendar") {
222
+ return calendarFailure(
223
+ "NotACalendar",
224
+ feed.name
225
+ ? `expected a VCALENDAR, found ${feed.name.toUpperCase()}`
226
+ : "expected a VCALENDAR, found nothing",
227
+ );
228
+ }
229
+
230
+ const timezones = feed.getAllSubcomponents("vtimezone");
231
+ const byUid = new Map<string, ICAL.Component[]>();
232
+ let unreadable = 0;
233
+ for (const event of feed.getAllSubcomponents("vevent")) {
234
+ const uid = event.getFirstPropertyValue("uid");
235
+ if (typeof uid !== "string" || uid === "") {
236
+ unreadable += 1;
237
+ continue;
238
+ }
239
+ byUid.set(uid, [...(byUid.get(uid) ?? []), event]);
240
+ }
241
+
242
+ const resources = [...byUid].map(([uid, events]) => {
243
+ const calendar = new ICAL.Component("vcalendar");
244
+ for (const property of feed.getAllProperties()) {
245
+ if (property.name === "method") continue;
246
+ calendar.addProperty(new ICAL.Property(property.toJSON()));
247
+ }
248
+ const zones = zonesReferencedBy(events);
249
+ for (const timezone of timezones) {
250
+ const tzid = timezone.getFirstPropertyValue("tzid");
251
+ if (typeof tzid === "string" && zones.has(tzid)) {
252
+ calendar.addSubcomponent(new ICAL.Component(timezone.toJSON()));
253
+ }
254
+ }
255
+ for (const event of events) {
256
+ calendar.addSubcomponent(new ICAL.Component(event.toJSON()));
257
+ }
258
+ return {
259
+ uid,
260
+ resourceName: resourceNameOf(uid),
261
+ icalData: serializeCalendar(calendar),
262
+ };
263
+ });
264
+
265
+ return { ok: true, value: { resources, unreadable } };
266
+ };
267
+
268
+ const feedErrorOf = (write: CalendarFeedWrite): string =>
269
+ write.skipped === 0
270
+ ? ""
271
+ : `${write.skipped} ${write.skipped === 1 ? "event" : "events"} in the feed could not be stored`;
272
+
273
+ /**
274
+ * Makes a subscribed collection hold what the feed holds, in one unit.
275
+ *
276
+ * Every resource goes through `putCalendarObject`, the one write path, so a
277
+ * subscribed event is stored, projected and expanded the way any other event
278
+ * is. A resource whose bytes have not changed is left alone, and one the feed
279
+ * no longer carries is removed. A resource the write path refuses is counted
280
+ * and recorded on the collection rather than failing the whole refresh.
281
+ */
282
+ export const writeCalendarFeed = async (
283
+ unitOfWork: ICalendarUnitOfWork,
284
+ target: CalendarSubscriptionTarget,
285
+ split: CalendarFeedSplit,
286
+ now: number,
287
+ ): Promise<CalendarFeedWrite> =>
288
+ unitOfWork.transaction(async (repos) => {
289
+ const current = await repos.calendarCollection.get(
290
+ target.accountConfigId,
291
+ target.calendarId,
292
+ );
293
+ if (!current.subscriptionEnabled) {
294
+ return { paused: true, written: 0, unchanged: 0, removed: 0, skipped: 0 };
295
+ }
296
+ const stored = await repos.calendarObject.listByCalendar(target.calendarId);
297
+ const storedByName = new Map(
298
+ stored.map((object) => [object.resourceName, object]),
299
+ );
300
+ const kept = new Set<string>();
301
+ const write: CalendarFeedWrite = {
302
+ paused: false,
303
+ written: 0,
304
+ unchanged: 0,
305
+ removed: 0,
306
+ skipped: split.unreadable,
307
+ };
308
+
309
+ for (const resource of split.resources) {
310
+ const existing = storedByName.get(resource.resourceName);
311
+ if (
312
+ existing &&
313
+ comparable(existing.icalData) === comparable(resource.icalData)
314
+ ) {
315
+ kept.add(resource.resourceName);
316
+ write.unchanged += 1;
317
+ continue;
318
+ }
319
+ const put = await putCalendarObject(unitOfWork, {
320
+ accountConfigId: target.accountConfigId,
321
+ calendarId: target.calendarId,
322
+ resourceName: resource.resourceName,
323
+ icalData: resource.icalData,
324
+ });
325
+ if (!put.ok) {
326
+ write.skipped += 1;
327
+ continue;
328
+ }
329
+ kept.add(resource.resourceName);
330
+ write.written += 1;
331
+ }
332
+
333
+ for (const object of stored) {
334
+ if (kept.has(object.resourceName)) continue;
335
+ await deleteCalendarObject(unitOfWork, {
336
+ accountConfigId: target.accountConfigId,
337
+ calendarId: target.calendarId,
338
+ calendarObjectId: object.calendarObjectId,
339
+ });
340
+ write.removed += 1;
341
+ }
342
+
343
+ await repos.calendarCollection.update(
344
+ target.accountConfigId,
345
+ target.calendarId,
346
+ {
347
+ subscriptionCheckedAt: now,
348
+ subscriptionFetchedAt: now,
349
+ subscriptionError: truncate(feedErrorOf(write)),
350
+ },
351
+ );
352
+ return write;
353
+ });
354
+
355
+ /** Records a refresh that stored nothing, and why. Every stored event stays. */
356
+ export const recordCalendarFeedFailure = async (
357
+ unitOfWork: ICalendarUnitOfWork,
358
+ target: CalendarSubscriptionTarget,
359
+ reason: string,
360
+ now: number,
361
+ ): Promise<void> => {
362
+ await unitOfWork.transaction((repos) =>
363
+ repos.calendarCollection.update(target.accountConfigId, target.calendarId, {
364
+ subscriptionCheckedAt: now,
365
+ subscriptionError: truncate(reason),
366
+ }),
367
+ );
368
+ };
369
+
370
+ /** Fetches and cuts a feed, or answers why it cannot be stored. */
371
+ export const readCalendarFeed = async (
372
+ url: string,
373
+ fetcher: CalendarFeedFetcher = fetch,
374
+ ): Promise<
375
+ { ok: true; value: CalendarFeedSplit } | { ok: false; reason: string }
376
+ > => {
377
+ const fetched = await fetchCalendarFeed(url, fetcher);
378
+ if (!fetched.ok) return fetched;
379
+ const split = await splitCalendarFeed(fetched.value);
380
+ if (!split.ok) {
381
+ return {
382
+ ok: false,
383
+ reason: `the feed is not a calendar: ${split.error.message}`,
384
+ };
385
+ }
386
+ return split;
387
+ };
388
+
389
+ /**
390
+ * One scheduled refresh of one subscription. A feed that cannot be read
391
+ * records its reason on the collection and removes nothing.
392
+ */
393
+ export const refreshCalendarSubscription = async (
394
+ unitOfWork: ICalendarUnitOfWork,
395
+ collection: CalendarCollectionItem,
396
+ now: number,
397
+ fetcher: CalendarFeedFetcher = fetch,
398
+ ): Promise<CalendarSubscriptionRefresh> => {
399
+ const target = {
400
+ accountConfigId: collection.accountConfigId,
401
+ calendarId: collection.calendarId,
402
+ };
403
+ const feed = await readCalendarFeed(collection.subscriptionUrl, fetcher);
404
+ if (!feed.ok) {
405
+ await recordCalendarFeedFailure(unitOfWork, target, feed.reason, now);
406
+ return feed;
407
+ }
408
+ const write = await writeCalendarFeed(unitOfWork, target, feed.value, now);
409
+ return { ok: true, value: write };
410
+ };
411
+
412
+ /**
413
+ * Whether a subscription is due a refresh: its last fetch, successful or not,
414
+ * is at least `intervalMs` old.
415
+ */
416
+ export const isSubscriptionDue = (
417
+ collection: CalendarCollectionItem,
418
+ now: number,
419
+ intervalMs: number,
420
+ ): boolean => now - collection.subscriptionCheckedAt >= intervalMs;
@@ -36,6 +36,11 @@ const collection = (
36
36
  source: CalendarSource.Default,
37
37
  timezone: "",
38
38
  syncSequence: 1,
39
+ subscriptionUrl: "",
40
+ subscriptionEnabled: false,
41
+ subscriptionCheckedAt: 0,
42
+ subscriptionFetchedAt: 0,
43
+ subscriptionError: "",
39
44
  createdAt: 0,
40
45
  updatedAt: 0,
41
46
  ...overrides,