@remit/calendar-service 0.0.1 → 0.0.2

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/src/expand.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import type { CalendarOccurrenceInput } from "@remit/data-ports";
2
2
  import ICAL from "ical.js";
3
3
  import type { ParsedCalendar } from "./parse.js";
4
- import { hasRecurrence } from "./project.js";
5
- import { resolveTime, toUtcIso, tzidOf } from "./time.js";
4
+ import { hasRecurrence, projectEventDisplay } from "./project.js";
5
+ import {
6
+ dtEndTzid,
7
+ dtStartTzid,
8
+ resolveTime,
9
+ toUtcIso,
10
+ tzidOf,
11
+ } from "./time.js";
6
12
 
7
13
  /**
8
14
  * How far past a series' own start its occurrences are written out.
@@ -23,6 +29,17 @@ export const CALENDAR_EXPANSION_HORIZON_DAYS = 730;
23
29
  */
24
30
  export const CALENDAR_EXPANSION_MAX_OCCURRENCES = 1000;
25
31
 
32
+ /**
33
+ * Ceiling on iterator steps a live window expansion may take.
34
+ *
35
+ * A window expansion walks the series from its own start, because a rule is
36
+ * only meaningful from where it was anchored. A daily event written in 2005 is
37
+ * a few thousand steps away from today, which is cheap; a per-minute one is
38
+ * millions, which is a read that never returns. The cap turns the second case
39
+ * into a short answer rather than a hung request.
40
+ */
41
+ export const CALENDAR_WINDOW_MAX_STEPS = 50_000;
42
+
26
43
  const HORIZON_MS = CALENDAR_EXPANSION_HORIZON_DAYS * 24 * 60 * 60 * 1000;
27
44
 
28
45
  export interface CalendarExpansion {
@@ -34,14 +51,68 @@ export interface CalendarExpansion {
34
51
  expandedThrough: string;
35
52
  }
36
53
 
54
+ const occurrenceOf = (
55
+ collectionTimezone: string,
56
+ recurrenceId: string,
57
+ source: ICAL.Component,
58
+ startDate: ICAL.Time,
59
+ endDate: ICAL.Time,
60
+ startTzid: string,
61
+ endTzid: string,
62
+ ): CalendarOccurrenceInput => {
63
+ const start = resolveTime(startDate, startTzid, collectionTimezone);
64
+ const end = resolveTime(endDate, endTzid, collectionTimezone);
65
+ return {
66
+ recurrenceId,
67
+ startAt: start.isoUtc,
68
+ endAt: end.isoUtc,
69
+ allDay: start.isDate,
70
+ ...projectEventDisplay(source),
71
+ };
72
+ };
73
+
37
74
  /**
38
- * Flattens a resource into the occurrence rows a date-range read returns.
39
- *
40
- * A non-recurring resource is one row under an empty `recurrenceId`. A
41
- * recurring one is a row per occurrence, each keyed by its RECURRENCE-ID slot
42
- * as a UTC instant — the same canonical form an override's own RECURRENCE-ID
43
- * resolves to, so an override lands on the occurrence it replaces instead of
44
- * beside it.
75
+ * The slot each override claims, canonicalized the way the iterator's slots
76
+ * are, so the two can be compared at all.
77
+ */
78
+ export const overridesBySlot = (
79
+ calendar: ParsedCalendar,
80
+ collectionTimezone: string,
81
+ ): Map<string, ICAL.Component> => {
82
+ const bySlot = new Map<string, ICAL.Component>();
83
+ for (const override of calendar.overrides) {
84
+ const recurrenceId = override.getFirstPropertyValue("recurrence-id");
85
+ if (!(recurrenceId instanceof ICAL.Time)) continue;
86
+ const slot = resolveTime(
87
+ recurrenceId,
88
+ tzidOf(override.getFirstProperty("recurrence-id")),
89
+ collectionTimezone,
90
+ ).isoUtc;
91
+ bySlot.set(slot, override);
92
+ }
93
+ return bySlot;
94
+ };
95
+
96
+ /** The window a walk collects occurrences in, and what bounds the walk. */
97
+ interface WalkBounds {
98
+ /** Occurrences starting before this are stepped past, not collected. */
99
+ fromMs: number;
100
+ /** The walk stops once a slot starts after this. */
101
+ throughMs: number;
102
+ maxOccurrences: number;
103
+ maxSteps: number;
104
+ }
105
+
106
+ interface Walk {
107
+ occurrences: CalendarOccurrenceInput[];
108
+ /** Whether the walk stopped on a bound rather than on the series ending. */
109
+ truncated: boolean;
110
+ /** Start of the last slot the iterator itself produced, or `""` for none. */
111
+ lastIteratedStart: string;
112
+ }
113
+
114
+ /**
115
+ * Walks a recurring resource's occurrences between the bounds it is given.
45
116
  *
46
117
  * EXDATEs are ical.js's business — `ICAL.Event` skips them. Overrides are not
47
118
  * left entirely to it: the iterator yields the master's own RRULE and RDATE
@@ -50,137 +121,176 @@ export interface CalendarExpansion {
50
121
  * editing the rule — would never be reached. Those are walked explicitly, so
51
122
  * every VEVENT in the resource is an occurrence somebody can find.
52
123
  */
53
- export const expandCalendar = (
124
+ const walkOccurrences = (
54
125
  calendar: ParsedCalendar,
55
126
  collectionTimezone: string,
56
- ): CalendarExpansion => {
57
- const resolve = (time: ICAL.Time, tzid: string) =>
58
- resolveTime(time, tzid, collectionTimezone);
59
-
60
- const startTzidOf = (component: ICAL.Component): string =>
61
- tzidOf(component.getFirstProperty("dtstart"));
62
-
63
- // DTEND carries its own TZID and need not match DTSTART's, so an end read
64
- // with the start's zone silently changes the event's length. Only a stated
65
- // DTEND has a zone of its own: an end ical.js derived from a duration is
66
- // already in the start's zone, and hinting it with anything else is wrong.
67
- const endTzidOf = (component: ICAL.Component): string =>
68
- component.hasProperty("dtend")
69
- ? tzidOf(component.getFirstProperty("dtend"))
70
- : startTzidOf(component);
71
-
72
- const occurrenceOf = (
73
- recurrenceId: string,
74
- startDate: ICAL.Time,
75
- endDate: ICAL.Time,
76
- startTzid: string,
77
- endTzid: string,
78
- ): CalendarOccurrenceInput => {
79
- const start = resolve(startDate, startTzid);
80
- const end = resolve(endDate, endTzid);
81
- return {
82
- recurrenceId,
83
- startAt: start.isoUtc,
84
- endAt: end.isoUtc,
85
- allDay: start.isDate,
86
- };
87
- };
88
-
127
+ bounds: WalkBounds,
128
+ ): Walk => {
89
129
  const event = new ICAL.Event(calendar.master);
90
130
  for (const override of calendar.overrides) {
91
131
  event.relateException(override);
92
132
  }
93
133
 
94
- if (!hasRecurrence(calendar)) {
95
- return {
96
- occurrences: [
97
- occurrenceOf(
98
- "",
99
- event.startDate,
100
- event.endDate,
101
- startTzidOf(calendar.master),
102
- endTzidOf(calendar.master),
103
- ),
104
- ],
105
- expandedThrough: "",
106
- };
107
- }
108
-
109
- // The slot each override claims, canonicalized the same way the iterator's
110
- // slots are, so the two can be compared at all.
111
- const overrideBySlot = new Map<string, ICAL.Component>();
112
- for (const override of calendar.overrides) {
113
- const recurrenceId = override.getFirstPropertyValue("recurrence-id");
114
- if (!(recurrenceId instanceof ICAL.Time)) continue;
115
- overrideBySlot.set(
116
- resolve(recurrenceId, tzidOf(override.getFirstProperty("recurrence-id")))
117
- .isoUtc,
118
- override,
119
- );
120
- }
121
-
122
- const seriesStart = resolve(
123
- event.startDate,
124
- startTzidOf(calendar.master),
125
- ).instantMs;
126
- const horizonMs = seriesStart + HORIZON_MS;
134
+ const overrideBySlot = overridesBySlot(calendar, collectionTimezone);
135
+ const masterStartTzid = dtStartTzid(calendar.master);
127
136
  const occurrences: CalendarOccurrenceInput[] = [];
128
137
  const claimed = new Set<string>();
129
138
  const iterator = event.iterator();
130
139
  let truncated = false;
140
+ let steps = 0;
141
+ let lastIteratedStart = "";
131
142
 
132
143
  let next = iterator.next();
133
144
  while (next) {
134
- const slot = resolve(next, startTzidOf(calendar.master));
145
+ const slot = resolveTime(next, masterStartTzid, collectionTimezone);
135
146
  if (
136
- slot.instantMs > horizonMs ||
137
- occurrences.length >= CALENDAR_EXPANSION_MAX_OCCURRENCES
147
+ slot.instantMs > bounds.throughMs ||
148
+ occurrences.length >= bounds.maxOccurrences ||
149
+ steps >= bounds.maxSteps
138
150
  ) {
139
151
  truncated = true;
140
152
  break;
141
153
  }
142
-
143
- // An overridden slot hands back the override's own DTSTART and DTEND, so
144
- // the zones to read them in are the override's. A plain slot hands back
145
- // the rule's start and an end derived from the master's duration, both in
146
- // the master's start zone.
147
- const source = overrideBySlot.get(slot.isoUtc);
148
- const details = event.getOccurrenceDetails(next);
154
+ steps += 1;
149
155
  claimed.add(slot.isoUtc);
150
- occurrences.push(
151
- occurrenceOf(
152
- slot.isoUtc,
153
- details.startDate,
154
- details.endDate,
155
- startTzidOf(source ?? calendar.master),
156
- source ? endTzidOf(source) : startTzidOf(calendar.master),
157
- ),
158
- );
156
+
157
+ if (slot.instantMs >= bounds.fromMs) {
158
+ // An overridden slot hands back the override's own DTSTART and DTEND, so
159
+ // the zones to read them in are the override's. A plain slot hands back
160
+ // the rule's start and an end derived from the master's duration, both in
161
+ // the master's start zone.
162
+ const source = overrideBySlot.get(slot.isoUtc);
163
+ const details = event.getOccurrenceDetails(next);
164
+ occurrences.push(
165
+ occurrenceOf(
166
+ collectionTimezone,
167
+ slot.isoUtc,
168
+ source ?? calendar.master,
169
+ details.startDate,
170
+ details.endDate,
171
+ dtStartTzid(source ?? calendar.master),
172
+ source ? dtEndTzid(source) : masterStartTzid,
173
+ ),
174
+ );
175
+ lastIteratedStart = occurrences[occurrences.length - 1]?.startAt ?? "";
176
+ }
159
177
  next = iterator.next();
160
178
  }
161
179
 
162
- const iterated = occurrences[occurrences.length - 1];
163
- const expandedThrough =
164
- truncated && iterated ? toUtcIso(Date.parse(iterated.startAt)) : "";
165
-
166
- // Overrides the rule never reached. Written regardless of the horizon: they
167
- // are a bounded, explicit list, and one moved instance is exactly the thing
168
- // a user goes looking for.
180
+ // Overrides the rule never reached. Kept regardless of the horizon: they are
181
+ // a bounded, explicit list, and one moved instance is exactly the thing a
182
+ // user goes looking for.
169
183
  for (const [slot, override] of overrideBySlot) {
170
184
  if (claimed.has(slot)) continue;
171
185
  const overrideEvent = new ICAL.Event(override);
172
- occurrences.push(
173
- occurrenceOf(
174
- slot,
175
- overrideEvent.startDate,
176
- overrideEvent.endDate,
177
- startTzidOf(override),
178
- endTzidOf(override),
179
- ),
186
+ const occurrence = occurrenceOf(
187
+ collectionTimezone,
188
+ slot,
189
+ override,
190
+ overrideEvent.startDate,
191
+ overrideEvent.endDate,
192
+ dtStartTzid(override),
193
+ dtEndTzid(override),
180
194
  );
195
+ const startMs = Date.parse(occurrence.startAt);
196
+ if (startMs < bounds.fromMs || startMs > bounds.throughMs) continue;
197
+ occurrences.push(occurrence);
181
198
  }
182
199
 
183
200
  occurrences.sort((left, right) => left.startAt.localeCompare(right.startAt));
184
201
 
185
- return { occurrences, expandedThrough };
202
+ return { occurrences, truncated, lastIteratedStart };
203
+ };
204
+
205
+ /** The one occurrence a resource that does not recur produces. */
206
+ const singleOccurrence = (
207
+ calendar: ParsedCalendar,
208
+ collectionTimezone: string,
209
+ ): CalendarOccurrenceInput => {
210
+ const event = new ICAL.Event(calendar.master);
211
+ return occurrenceOf(
212
+ collectionTimezone,
213
+ "",
214
+ calendar.master,
215
+ event.startDate,
216
+ event.endDate,
217
+ dtStartTzid(calendar.master),
218
+ dtEndTzid(calendar.master),
219
+ );
220
+ };
221
+
222
+ /**
223
+ * Flattens a resource into the occurrence rows a date-range read returns.
224
+ *
225
+ * A non-recurring resource is one row under an empty `recurrenceId`. A
226
+ * recurring one is a row per occurrence, each keyed by its RECURRENCE-ID slot
227
+ * as a UTC instant — the same canonical form an override's own RECURRENCE-ID
228
+ * resolves to, so an override lands on the occurrence it replaces instead of
229
+ * beside it.
230
+ *
231
+ * The horizon is anchored at the series' own start, not at today: two years of
232
+ * a series is a bounded write whatever a caller's clock says. A series running
233
+ * past it comes back marked with `expandedThrough`, which is what tells a later
234
+ * read to expand that resource live instead of trusting the index.
235
+ */
236
+ export const expandCalendar = (
237
+ calendar: ParsedCalendar,
238
+ collectionTimezone: string,
239
+ ): CalendarExpansion => {
240
+ if (!hasRecurrence(calendar)) {
241
+ return {
242
+ occurrences: [singleOccurrence(calendar, collectionTimezone)],
243
+ expandedThrough: "",
244
+ };
245
+ }
246
+
247
+ const event = new ICAL.Event(calendar.master);
248
+ const seriesStart = resolveTime(
249
+ event.startDate,
250
+ dtStartTzid(calendar.master),
251
+ collectionTimezone,
252
+ ).instantMs;
253
+
254
+ const walk = walkOccurrences(calendar, collectionTimezone, {
255
+ fromMs: Number.NEGATIVE_INFINITY,
256
+ throughMs: seriesStart + HORIZON_MS,
257
+ maxOccurrences: CALENDAR_EXPANSION_MAX_OCCURRENCES,
258
+ maxSteps: Number.POSITIVE_INFINITY,
259
+ });
260
+
261
+ return {
262
+ occurrences: walk.occurrences,
263
+ expandedThrough:
264
+ walk.truncated && walk.lastIteratedStart
265
+ ? toUtcIso(Date.parse(walk.lastIteratedStart))
266
+ : "",
267
+ };
268
+ };
269
+
270
+ /**
271
+ * The occurrences of a resource that fall inside one window, computed rather
272
+ * than read.
273
+ *
274
+ * This is what serves a view of a series whose stored index stops before the
275
+ * window — an open-ended weekly meeting written years ago, whose horizon ran
276
+ * out long before today. The alternative is extending the index on read, which
277
+ * turns every view of an old series into an unbounded write; a bounded
278
+ * computation that persists nothing is the cheaper half of that trade, and the
279
+ * one that cannot leave anything behind to be wrong later.
280
+ */
281
+ export const expandCalendarWindow = (
282
+ calendar: ParsedCalendar,
283
+ collectionTimezone: string,
284
+ window: { fromMs: number; toMs: number },
285
+ ): CalendarOccurrenceInput[] => {
286
+ if (!hasRecurrence(calendar)) {
287
+ return [singleOccurrence(calendar, collectionTimezone)];
288
+ }
289
+
290
+ return walkOccurrences(calendar, collectionTimezone, {
291
+ fromMs: window.fromMs,
292
+ throughMs: window.toMs,
293
+ maxOccurrences: CALENDAR_EXPANSION_MAX_OCCURRENCES,
294
+ maxSteps: CALENDAR_WINDOW_MAX_STEPS,
295
+ }).occurrences;
186
296
  };
package/src/index.ts CHANGED
@@ -1,3 +1,12 @@
1
+ export {
2
+ applyEventFields,
3
+ buildEventCalendar,
4
+ CALENDAR_PRODID,
5
+ type CalendarEventFields,
6
+ eventTimeFields,
7
+ readEventTime,
8
+ readRecurrenceRule,
9
+ } from "./build.js";
1
10
  export type {
2
11
  CalendarResult,
3
12
  CalendarValidationCode,
@@ -7,8 +16,10 @@ export { computeEtag } from "./etag.js";
7
16
  export {
8
17
  CALENDAR_EXPANSION_HORIZON_DAYS,
9
18
  CALENDAR_EXPANSION_MAX_OCCURRENCES,
19
+ CALENDAR_WINDOW_MAX_STEPS,
10
20
  type CalendarExpansion,
11
21
  expandCalendar,
22
+ expandCalendarWindow,
12
23
  } from "./expand.js";
13
24
  export {
14
25
  type ParsedCalendar,
@@ -26,4 +37,30 @@ export {
26
37
  provisionDefaultCalendar,
27
38
  putCalendarObject,
28
39
  } from "./put.js";
29
- export { type ResolvedTime, resolveTime, toUtcIso } from "./time.js";
40
+ export {
41
+ applyScopedDelete,
42
+ applyScopedUpdate,
43
+ findOccurrence,
44
+ type RecurrenceScopeValue,
45
+ type ScopedWrite,
46
+ type ScopedWriteInput,
47
+ } from "./scope.js";
48
+ export {
49
+ civilInZone,
50
+ isResolvableZone,
51
+ type ResolvedTime,
52
+ resolveTime,
53
+ toOffsetIso,
54
+ toUtcIso,
55
+ } from "./time.js";
56
+ export {
57
+ type BusySpan,
58
+ CALENDAR_WINDOW_LOOKBACK_DAYS,
59
+ type CalendarInstance,
60
+ type CalendarWindow,
61
+ type CalendarWindowRepositories,
62
+ isBusy,
63
+ listBusySpans,
64
+ listCalendarInstances,
65
+ mergeBusySpans,
66
+ } from "./window.js";
package/src/project.ts CHANGED
@@ -48,6 +48,31 @@ const readString = (component: ICAL.Component, name: string): string => {
48
48
  * but a master and its exceptions, and every one of those is still an
49
49
  * occurrence somebody has to see.
50
50
  */
51
+ /**
52
+ * The fields that describe one VEVENT rather than the series it belongs to.
53
+ *
54
+ * Read per occurrence as well as per resource: an override VEVENT carries its
55
+ * own summary, status and transparency, and a range read that took them from
56
+ * the master would draw the old title over an edited instance and count a
57
+ * cancelled one as busy time.
58
+ */
59
+ export type CalendarEventDisplay = Pick<
60
+ CalendarObjectItem,
61
+ "summary" | "status" | "transparency"
62
+ >;
63
+
64
+ export const projectEventDisplay = (
65
+ component: ICAL.Component,
66
+ ): CalendarEventDisplay => ({
67
+ summary: readString(component, "summary"),
68
+ status:
69
+ STATUS_BY_ICAL[readString(component, "status").toUpperCase()] ??
70
+ CalendarEventStatus.Confirmed,
71
+ transparency:
72
+ TRANSPARENCY_BY_ICAL[readString(component, "transp").toUpperCase()] ??
73
+ CalendarTransparency.Opaque,
74
+ });
75
+
51
76
  export const hasRecurrence = (calendar: ParsedCalendar): boolean =>
52
77
  calendar.master.hasProperty("rrule") ||
53
78
  calendar.master.hasProperty("rdate") ||
@@ -85,18 +110,11 @@ export const projectCalendar = (
85
110
  ok: true,
86
111
  value: {
87
112
  icalUid: calendar.uid,
88
- summary: readString(calendar.master, "summary"),
89
113
  dtStart: start.isoOffset,
90
114
  dtEnd: end.isoOffset,
91
115
  allDay: start.isDate,
92
116
  zoneCertainty: start.certainty,
93
- status:
94
- STATUS_BY_ICAL[readString(calendar.master, "status").toUpperCase()] ??
95
- CalendarEventStatus.Confirmed,
96
- transparency:
97
- TRANSPARENCY_BY_ICAL[
98
- readString(calendar.master, "transp").toUpperCase()
99
- ] ?? CalendarTransparency.Opaque,
117
+ ...projectEventDisplay(calendar.master),
100
118
  hasRecurrence: hasRecurrence(calendar),
101
119
  sequence: typeof sequence === "number" ? sequence : 0,
102
120
  },
package/src/put.test.ts CHANGED
@@ -88,6 +88,14 @@ class MemoryCalendarStore implements ICalendarUnitOfWork {
88
88
  [...this.collections.values()].filter(
89
89
  (collection) => collection.accountConfigId === accountConfigId,
90
90
  ),
91
+ createExclusive: async (input: CreateCalendarCollectionInput) => {
92
+ const calendarId = deriveCalendarId(
93
+ input.accountConfigId,
94
+ input.urlSegment,
95
+ );
96
+ if (this.collections.has(calendarId)) return null;
97
+ return this.collectionRepo.create(input);
98
+ },
91
99
  findByUrlSegment: async (accountConfigId: string, urlSegment: string) =>
92
100
  this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
93
101
  null,
@@ -119,6 +127,15 @@ class MemoryCalendarStore implements ICalendarUnitOfWork {
119
127
  this.objects.set(calendarObjectId, object);
120
128
  return object;
121
129
  },
130
+ listIncompleteExpansions: async (calendarId: string, instant: string) =>
131
+ [...this.objects.values()].filter(
132
+ (object) =>
133
+ object.calendarId === calendarId &&
134
+ object.expandedThrough !== "" &&
135
+ object.expandedThrough < instant,
136
+ ),
137
+ find: async (_calendarId: string, calendarObjectId: string) =>
138
+ this.objects.get(calendarObjectId) ?? null,
122
139
  get: async (_calendarId: string, calendarObjectId: string) => {
123
140
  const object = this.objects.get(calendarObjectId);
124
141
  if (!object) throw new MissingRow(calendarObjectId);