@remit/calendar-service 0.0.18 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/accept.d.ts +71 -0
  2. package/dist/accept.js +159 -0
  3. package/dist/build.d.ts +79 -0
  4. package/dist/build.js +196 -0
  5. package/dist/correct.d.ts +4 -0
  6. package/dist/correct.js +22 -0
  7. package/dist/errors.d.ts +56 -0
  8. package/dist/errors.js +1 -0
  9. package/dist/etag.d.ts +9 -0
  10. package/{src/etag.ts → dist/etag.js} +1 -3
  11. package/dist/expand.d.ts +73 -0
  12. package/dist/expand.js +188 -0
  13. package/dist/feed.d.ts +81 -0
  14. package/dist/feed.js +157 -0
  15. package/dist/fixtures.d.ts +19 -0
  16. package/dist/fixtures.js +37 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.js +15 -0
  19. package/dist/memory-store.d.ts +105 -0
  20. package/dist/memory-store.js +319 -0
  21. package/dist/parse.d.ts +39 -0
  22. package/dist/parse.js +87 -0
  23. package/dist/project.d.ts +30 -0
  24. package/dist/project.js +57 -0
  25. package/dist/put.d.ts +52 -0
  26. package/dist/put.js +78 -0
  27. package/dist/reopen.d.ts +14 -0
  28. package/dist/reopen.js +92 -0
  29. package/dist/scope.d.ts +70 -0
  30. package/dist/scope.js +687 -0
  31. package/dist/subscribe.d.ts +96 -0
  32. package/dist/subscribe.js +298 -0
  33. package/dist/suggest.d.ts +74 -0
  34. package/dist/suggest.js +138 -0
  35. package/dist/time.d.ts +76 -0
  36. package/dist/time.js +164 -0
  37. package/dist/window.d.ts +85 -0
  38. package/dist/window.js +161 -0
  39. package/package.json +40 -35
  40. package/src/accept.test.ts +0 -403
  41. package/src/accept.ts +0 -250
  42. package/src/build.test.ts +0 -113
  43. package/src/build.ts +0 -311
  44. package/src/correct.test.ts +0 -173
  45. package/src/correct.ts +0 -40
  46. package/src/errors.ts +0 -62
  47. package/src/etag.test.ts +0 -54
  48. package/src/expand.test.ts +0 -361
  49. package/src/expand.ts +0 -303
  50. package/src/feed.test.ts +0 -334
  51. package/src/feed.ts +0 -203
  52. package/src/feeds/google-secret-address.ics +0 -99
  53. package/src/fixtures.ts +0 -52
  54. package/src/index.ts +0 -128
  55. package/src/memory-store.ts +0 -485
  56. package/src/parse.test.ts +0 -264
  57. package/src/parse.ts +0 -138
  58. package/src/project.test.ts +0 -247
  59. package/src/project.ts +0 -122
  60. package/src/put.test.ts +0 -318
  61. package/src/put.ts +0 -133
  62. package/src/reopen.test.ts +0 -529
  63. package/src/reopen.ts +0 -199
  64. package/src/scope.test.ts +0 -1421
  65. package/src/scope.ts +0 -1003
  66. package/src/subscribe.test.ts +0 -582
  67. package/src/subscribe.ts +0 -433
  68. package/src/suggest.test.ts +0 -376
  69. package/src/suggest.ts +0 -244
  70. package/src/time.ts +0 -260
  71. package/src/window.test.ts +0 -648
  72. package/src/window.ts +0 -281
  73. package/tsconfig.json +0 -8
package/src/subscribe.ts DELETED
@@ -1,433 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import type {
3
- CalendarCollectionItem,
4
- CalendarWrite,
5
- ICalendarStore,
6
- } from "@remit/data-ports";
7
- import ICAL from "ical.js";
8
- import { type CalendarResult, calendarFailure } from "./errors.js";
9
- import { readCalendarComponent, serializeCalendar } from "./parse.js";
10
- import { deleteCalendarObjectWrite, prepareCalendarObject } from "./put.js";
11
-
12
- export const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30_000;
13
-
14
- export const CALENDAR_SUBSCRIPTION_MAX_BYTES = 10 * 1024 * 1024;
15
-
16
- const SUBSCRIPTION_ERROR_MAX_LENGTH = 512;
17
-
18
- const SCHEMES: Record<string, string> = {
19
- "http:": "http:",
20
- "https:": "https:",
21
- "webcal:": "https:",
22
- "webcals:": "https:",
23
- };
24
-
25
- export type CalendarFeedFetcher = (
26
- url: string,
27
- init: RequestInit,
28
- ) => Promise<Response>;
29
-
30
- export type CalendarFeedFetch =
31
- | { ok: true; value: string }
32
- | { ok: false; reason: string };
33
-
34
- export interface CalendarFeedResource {
35
- uid: string;
36
- resourceName: string;
37
- icalData: string;
38
- }
39
-
40
- export interface CalendarFeedSplit {
41
- resources: CalendarFeedResource[];
42
- unreadable: number;
43
- }
44
-
45
- export interface CalendarFeedWrite {
46
- /** The subscription was paused while its feed was being read, so nothing was written. */
47
- paused: boolean;
48
- written: number;
49
- unchanged: number;
50
- removed: number;
51
- skipped: number;
52
- }
53
-
54
- export interface CalendarSubscriptionTarget {
55
- accountConfigId: string;
56
- calendarId: string;
57
- }
58
-
59
- export type CalendarSubscriptionRefresh =
60
- | { ok: true; value: CalendarFeedWrite }
61
- | { ok: false; reason: string };
62
-
63
- /**
64
- * The URL a subscription is fetched from. `webcal:` is what calendar products
65
- * hand out for "subscribe", and means the same address over HTTPS.
66
- */
67
- export const readSubscriptionUrl = (raw: string): CalendarResult<string> => {
68
- const trimmed = raw.trim();
69
- if (!URL.canParse(trimmed)) {
70
- return calendarFailure(
71
- "InvalidSubscriptionUrl",
72
- "the subscription address is not a URL",
73
- );
74
- }
75
- const url = new URL(trimmed);
76
- const scheme = SCHEMES[url.protocol];
77
- if (!scheme) {
78
- return calendarFailure(
79
- "InvalidSubscriptionUrl",
80
- `a subscription address starts with http, https or webcal, not ${url.protocol.replace(/:$/, "")}`,
81
- );
82
- }
83
- if (url.username !== "" || url.password !== "") {
84
- return calendarFailure(
85
- "InvalidSubscriptionUrl",
86
- "a subscription address carries no user name or password",
87
- );
88
- }
89
- const address = new URL(`${scheme}${trimmed.slice(url.protocol.length)}`);
90
- return { ok: true, value: address.toString() };
91
- };
92
-
93
- const truncate = (reason: string): string =>
94
- reason.length <= SUBSCRIPTION_ERROR_MAX_LENGTH
95
- ? reason
96
- : `${reason.slice(0, SUBSCRIPTION_ERROR_MAX_LENGTH - 1)}…`;
97
-
98
- const unreachableReason = (error: unknown): string => {
99
- if (error instanceof Error && error.name === "TimeoutError") {
100
- return `the feed did not answer within ${CALENDAR_SUBSCRIPTION_TIMEOUT_MS / 1000} seconds`;
101
- }
102
- const cause =
103
- error instanceof Error && error.cause instanceof Error
104
- ? error.cause
105
- : undefined;
106
- const code =
107
- cause && "code" in cause && typeof cause.code === "string"
108
- ? cause.code
109
- : undefined;
110
- return code
111
- ? `the feed could not be reached (${code})`
112
- : "the feed could not be reached";
113
- };
114
-
115
- /**
116
- * Reads a subscription's feed. Every failure is a returned reason that a
117
- * person can act on and that never repeats the URL: for a Google secret
118
- * address the URL is the whole credential, and the reason is stored on the
119
- * collection and drawn in the calendar view.
120
- */
121
- export const fetchCalendarFeed = async (
122
- url: string,
123
- fetcher: CalendarFeedFetcher = fetch,
124
- ): Promise<CalendarFeedFetch> => {
125
- const response = await fetcher(url, {
126
- headers: { accept: "text/calendar, */*;q=0.5" },
127
- redirect: "follow",
128
- signal: AbortSignal.timeout(CALENDAR_SUBSCRIPTION_TIMEOUT_MS),
129
- }).then(
130
- (value) => ({ ok: true, value }) as const,
131
- (error: unknown) =>
132
- ({ ok: false, reason: unreachableReason(error) }) as const,
133
- );
134
- if (!response.ok) return response;
135
-
136
- const { status } = response.value;
137
- if (status < 200 || status > 299) {
138
- await response.value.body?.cancel();
139
- return { ok: false, reason: `the feed answered HTTP ${status}` };
140
- }
141
-
142
- const declared = Number(response.value.headers.get("content-length") ?? "0");
143
- if (declared > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
144
- await response.value.body?.cancel();
145
- return { ok: false, reason: TOO_LARGE };
146
- }
147
-
148
- const { body } = response.value;
149
- if (!body) return { ok: true, value: "" };
150
- return readCapped(body).then(
151
- (read) => read,
152
- (error: unknown) => ({ ok: false, reason: unreachableReason(error) }),
153
- );
154
- };
155
-
156
- const TOO_LARGE = "the feed is larger than 10 MB";
157
-
158
- /**
159
- * Reads a body a chunk at a time and stops at the cap. A feed sent chunked
160
- * carries no length to refuse up front, so counting as it arrives is the only
161
- * thing that keeps an endless body out of memory.
162
- */
163
- const readCapped = async (
164
- body: ReadableStream<Uint8Array>,
165
- ): Promise<CalendarFeedFetch> => {
166
- const reader = body.getReader();
167
- const decoder = new TextDecoder();
168
- let received = 0;
169
- let text = "";
170
- for (;;) {
171
- const { done, value } = await reader.read();
172
- if (done) break;
173
- received += value.byteLength;
174
- if (received > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
175
- await reader.cancel();
176
- return { ok: false, reason: TOO_LARGE };
177
- }
178
- text += decoder.decode(value, { stream: true });
179
- }
180
- return { ok: true, value: text + decoder.decode() };
181
- };
182
-
183
- /**
184
- * A resource as it would compare across two fetches. DTSTAMP is when the
185
- * provider exported the feed, not when the event changed — Google restamps it
186
- * on every fetch — so it is left out, or every refresh would rewrite every
187
- * event.
188
- */
189
- const comparable = (icalData: string): string =>
190
- icalData.replace(/^DTSTAMP[;:][^\r\n]*\r?\n/gim, "");
191
-
192
- const resourceNameOf = (uid: string): string =>
193
- `${createHash("sha256").update(uid, "utf8").digest("hex").slice(0, 40)}.ics`;
194
-
195
- const zonesReferencedBy = (events: ICAL.Component[]): Set<string> => {
196
- const zones = new Set<string>();
197
- for (const event of events) {
198
- for (const property of event.getAllProperties()) {
199
- const tzid = property.getParameter("tzid");
200
- if (typeof tzid === "string") zones.add(tzid);
201
- }
202
- }
203
- return zones;
204
- };
205
-
206
- /**
207
- * Cuts a whole feed into one resource per UID, the shape the calendar write
208
- * path stores: a series and its RECURRENCE-ID overrides travel together, with
209
- * the VTIMEZONEs they name, so a DST-crossing series resolves against the zone
210
- * the provider published.
211
- *
212
- * The feed's calendar-level properties are kept on every resource except
213
- * METHOD, which a stored resource never carries. A VEVENT with no UID has
214
- * nothing to key it by across refreshes, so it is counted and left out.
215
- */
216
- export const splitCalendarFeed = async (
217
- icalData: string,
218
- ): Promise<CalendarResult<CalendarFeedSplit>> => {
219
- const read = await readCalendarComponent(icalData);
220
- if (!read.ok) return read;
221
- const feed = read.value;
222
- if (feed.name !== "vcalendar") {
223
- return calendarFailure(
224
- "NotACalendar",
225
- feed.name
226
- ? `expected a VCALENDAR, found ${feed.name.toUpperCase()}`
227
- : "expected a VCALENDAR, found nothing",
228
- );
229
- }
230
-
231
- const timezones = feed.getAllSubcomponents("vtimezone");
232
- const byUid = new Map<string, ICAL.Component[]>();
233
- let unreadable = 0;
234
- for (const event of feed.getAllSubcomponents("vevent")) {
235
- const uid = event.getFirstPropertyValue("uid");
236
- if (typeof uid !== "string" || uid === "") {
237
- unreadable += 1;
238
- continue;
239
- }
240
- byUid.set(uid, [...(byUid.get(uid) ?? []), event]);
241
- }
242
-
243
- const resources = [...byUid].map(([uid, events]) => {
244
- const calendar = new ICAL.Component("vcalendar");
245
- for (const property of feed.getAllProperties()) {
246
- if (property.name === "method") continue;
247
- calendar.addProperty(new ICAL.Property(property.toJSON()));
248
- }
249
- const zones = zonesReferencedBy(events);
250
- for (const timezone of timezones) {
251
- const tzid = timezone.getFirstPropertyValue("tzid");
252
- if (typeof tzid === "string" && zones.has(tzid)) {
253
- calendar.addSubcomponent(new ICAL.Component(timezone.toJSON()));
254
- }
255
- }
256
- for (const event of events) {
257
- calendar.addSubcomponent(new ICAL.Component(event.toJSON()));
258
- }
259
- return {
260
- uid,
261
- resourceName: resourceNameOf(uid),
262
- icalData: serializeCalendar(calendar),
263
- };
264
- });
265
-
266
- return { ok: true, value: { resources, unreadable } };
267
- };
268
-
269
- const feedErrorOf = (write: CalendarFeedWrite): string =>
270
- write.skipped === 0
271
- ? ""
272
- : `${write.skipped} ${write.skipped === 1 ? "event" : "events"} in the feed could not be stored`;
273
-
274
- /**
275
- * Makes a subscribed collection hold what the feed holds, in one commit.
276
- *
277
- * Every resource goes through `prepareCalendarObject`, the one write path, so
278
- * a subscribed event is stored, projected and expanded the way any other event
279
- * is. A resource whose bytes have not changed is left alone, and one the feed
280
- * no longer carries is removed. A resource the write path refuses is counted
281
- * and recorded on the collection rather than failing the whole refresh.
282
- *
283
- * Repeating a refresh converges: a resource is keyed by its name, so a second
284
- * run over the same feed finds every resource unchanged.
285
- */
286
- export const writeCalendarFeed = async (
287
- store: ICalendarStore,
288
- target: CalendarSubscriptionTarget,
289
- split: CalendarFeedSplit,
290
- now: number,
291
- ): Promise<CalendarFeedWrite> => {
292
- const current = await store.repos.calendarCollection.get(
293
- target.accountConfigId,
294
- target.calendarId,
295
- );
296
- if (!current.subscriptionEnabled) {
297
- return { paused: true, written: 0, unchanged: 0, removed: 0, skipped: 0 };
298
- }
299
- const stored = await store.repos.calendarObject.listByCalendar(
300
- target.calendarId,
301
- );
302
- const storedByName = new Map(
303
- stored.map((object) => [object.resourceName, object]),
304
- );
305
- const kept = new Set<string>();
306
- const writes: CalendarWrite[] = [];
307
- const write: CalendarFeedWrite = {
308
- paused: false,
309
- written: 0,
310
- unchanged: 0,
311
- removed: 0,
312
- skipped: split.unreadable,
313
- };
314
-
315
- for (const resource of split.resources) {
316
- const existing = storedByName.get(resource.resourceName);
317
- if (
318
- existing &&
319
- comparable(existing.icalData) === comparable(resource.icalData)
320
- ) {
321
- kept.add(resource.resourceName);
322
- write.unchanged += 1;
323
- continue;
324
- }
325
- const put = await prepareCalendarObject(current, {
326
- accountConfigId: target.accountConfigId,
327
- calendarId: target.calendarId,
328
- resourceName: resource.resourceName,
329
- icalData: resource.icalData,
330
- });
331
- if (!put.ok) {
332
- write.skipped += 1;
333
- continue;
334
- }
335
- writes.push(put.value);
336
- kept.add(resource.resourceName);
337
- write.written += 1;
338
- }
339
-
340
- for (const object of stored) {
341
- if (kept.has(object.resourceName)) continue;
342
- writes.push(
343
- deleteCalendarObjectWrite({
344
- accountConfigId: target.accountConfigId,
345
- calendarId: target.calendarId,
346
- calendarObjectId: object.calendarObjectId,
347
- }),
348
- );
349
- write.removed += 1;
350
- }
351
-
352
- writes.push({
353
- kind: "UpdateCollection",
354
- accountConfigId: target.accountConfigId,
355
- calendarId: target.calendarId,
356
- patch: {
357
- subscriptionCheckedAt: now,
358
- subscriptionFetchedAt: now,
359
- subscriptionError: truncate(feedErrorOf(write)),
360
- },
361
- });
362
- await store.commit(writes);
363
- return write;
364
- };
365
-
366
- /** Records a refresh that stored nothing, and why. Every stored event stays. */
367
- export const recordCalendarFeedFailure = async (
368
- store: ICalendarStore,
369
- target: CalendarSubscriptionTarget,
370
- reason: string,
371
- now: number,
372
- ): Promise<void> => {
373
- await store.repos.calendarCollection.update(
374
- target.accountConfigId,
375
- target.calendarId,
376
- {
377
- subscriptionCheckedAt: now,
378
- subscriptionError: truncate(reason),
379
- },
380
- );
381
- };
382
-
383
- /** Fetches and cuts a feed, or answers why it cannot be stored. */
384
- export const readCalendarFeed = async (
385
- url: string,
386
- fetcher: CalendarFeedFetcher = fetch,
387
- ): Promise<
388
- { ok: true; value: CalendarFeedSplit } | { ok: false; reason: string }
389
- > => {
390
- const fetched = await fetchCalendarFeed(url, fetcher);
391
- if (!fetched.ok) return fetched;
392
- const split = await splitCalendarFeed(fetched.value);
393
- if (!split.ok) {
394
- return {
395
- ok: false,
396
- reason: `the feed is not a calendar: ${split.error.message}`,
397
- };
398
- }
399
- return split;
400
- };
401
-
402
- /**
403
- * One scheduled refresh of one subscription. A feed that cannot be read
404
- * records its reason on the collection and removes nothing.
405
- */
406
- export const refreshCalendarSubscription = async (
407
- store: ICalendarStore,
408
- collection: CalendarCollectionItem,
409
- now: number,
410
- fetcher: CalendarFeedFetcher = fetch,
411
- ): Promise<CalendarSubscriptionRefresh> => {
412
- const target = {
413
- accountConfigId: collection.accountConfigId,
414
- calendarId: collection.calendarId,
415
- };
416
- const feed = await readCalendarFeed(collection.subscriptionUrl, fetcher);
417
- if (!feed.ok) {
418
- await recordCalendarFeedFailure(store, target, feed.reason, now);
419
- return feed;
420
- }
421
- const write = await writeCalendarFeed(store, target, feed.value, now);
422
- return { ok: true, value: write };
423
- };
424
-
425
- /**
426
- * Whether a subscription is due a refresh: its last fetch, successful or not,
427
- * is at least `intervalMs` old.
428
- */
429
- export const isSubscriptionDue = (
430
- collection: CalendarCollectionItem,
431
- now: number,
432
- intervalMs: number,
433
- ): boolean => now - collection.subscriptionCheckedAt >= intervalMs;