@remit/calendar-service 0.0.17 → 0.0.18

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.17",
3
+ "version": "0.0.18",
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/accept.ts CHANGED
@@ -2,8 +2,9 @@ import type { CalendarInviteAnswer as CalendarInviteAnswerValue } from "@remit/a
2
2
  import type {
3
3
  CalendarObjectItem,
4
4
  CalendarSuggestionItem,
5
- ICalendarUnitOfWork,
5
+ ICalendarStore,
6
6
  } from "@remit/data-ports";
7
+ import { deriveCalendarObjectId } from "@remit/data-ports/id";
7
8
  import {
8
9
  CalendarInviteAnswer,
9
10
  CalendarInviteMethod,
@@ -12,7 +13,7 @@ import {
12
13
  import ICAL from "ical.js";
13
14
  import type { CalendarResult } from "./errors.js";
14
15
  import { parseCalendar, serializeCalendar } from "./parse.js";
15
- import { putCalendarObject } from "./put.js";
16
+ import { prepareCalendarObject, readWrittenObject } from "./put.js";
16
17
  import { mailAddressOf } from "./suggest.js";
17
18
 
18
19
  const eventsOf = (component: ICAL.Component): ICAL.Component[] =>
@@ -132,15 +133,16 @@ export interface AcceptedCalendarSuggestion {
132
133
  }
133
134
 
134
135
  /**
135
- * Adds a suggested event to a calendar, as one unit.
136
+ * Adds a suggested event to a calendar, then settles the suggestion.
136
137
  *
137
138
  * The resource is written through `putCalendarObject`, the same function every
138
139
  * other calendar write goes through, so an event added from a mail and one
139
140
  * added from the week grid are the same kind of row — and a native client
140
- * editing it afterwards edits the same bytes. The suggestion's own state is
141
- * settled inside that transaction: a suggestion marked `Accepted` with no
142
- * resource behind it, or a resource with the card still asking, is a state
143
- * nothing later can repair.
141
+ * editing it afterwards edits the same bytes. The event and the suggestion
142
+ * live in different databases, so the commit writes the event first and
143
+ * settles the card second. A failure between the two leaves the card still
144
+ * asking with the event in the calendar, and accepting again repairs it: the
145
+ * event write lands on the same resource and the settle follows.
144
146
  *
145
147
  * Accepting a `Cancel` suggestion writes `STATUS:CANCELLED` through that same
146
148
  * path, but only onto a resource that is already there. Nothing withdraws an
@@ -157,7 +159,7 @@ export interface AcceptedCalendarSuggestion {
157
159
  * leaving the calendar showing the meeting twice.
158
160
  */
159
161
  export const acceptCalendarSuggestion = async (
160
- unitOfWork: ICalendarUnitOfWork,
162
+ store: ICalendarStore,
161
163
  input: AcceptCalendarSuggestionInput,
162
164
  ): Promise<CalendarResult<AcceptedCalendarSuggestion>> => {
163
165
  const icalData = await buildAcceptedCalendar(
@@ -167,63 +169,82 @@ export const acceptCalendarSuggestion = async (
167
169
  );
168
170
  if (!icalData.ok) return icalData;
169
171
 
170
- return unitOfWork.transaction(async (repos) => {
171
- const existing = await repos.calendarObject.findByUid(
172
- input.calendarId,
173
- input.suggestion.icalUid,
174
- );
175
-
176
- // A cancellation cancels something. With no resource carrying this UID
177
- // there is nothing in the calendar to withdraw, and writing one would
178
- // invent a meeting the user never had purely to mark it cancelled. Clear
179
- // the card and leave the calendar untouched.
180
- if (
181
- input.suggestion.method === CalendarInviteMethod.Cancel &&
182
- existing === null
183
- ) {
184
- const cleared = await repos.calendarSuggestion.settle(
185
- input.accountConfigId,
186
- input.suggestion.suggestionId,
187
- {
172
+ const existing = await store.repos.calendarObject.findByUid(
173
+ input.calendarId,
174
+ input.suggestion.icalUid,
175
+ );
176
+
177
+ // A cancellation cancels something. With no resource carrying this UID
178
+ // there is nothing in the calendar to withdraw, and writing one would
179
+ // invent a meeting the user never had purely to mark it cancelled. Clear
180
+ // the card and leave the calendar untouched.
181
+ if (
182
+ input.suggestion.method === CalendarInviteMethod.Cancel &&
183
+ existing === null
184
+ ) {
185
+ await store.commit([
186
+ {
187
+ kind: "SettleSuggestion",
188
+ accountConfigId: input.accountConfigId,
189
+ suggestionId: input.suggestion.suggestionId,
190
+ settle: {
188
191
  state: CalendarSuggestionState.Dismissed,
189
192
  acceptedCalendarObjectId: "",
190
193
  },
191
- );
192
- return {
193
- ok: true,
194
- value: {
195
- suggestion: cleared,
196
- outcome: "NothingToCancel",
197
- object: null,
198
- },
199
- };
200
- }
201
-
202
- const written = await putCalendarObject(unitOfWork, {
203
- accountConfigId: input.accountConfigId,
204
- calendarId: input.calendarId,
205
- resourceName:
206
- existing?.resourceName ?? `${input.suggestion.suggestionId}.ics`,
207
- icalData: icalData.value,
208
- });
209
- if (!written.ok) return written;
210
-
211
- const settled = await repos.calendarSuggestion.settle(
212
- input.accountConfigId,
213
- input.suggestion.suggestionId,
214
- {
215
- state: stateOf[input.answer],
216
- acceptedCalendarObjectId: written.value.calendarObjectId,
217
194
  },
218
- );
219
-
195
+ ]);
220
196
  return {
221
197
  ok: true,
222
198
  value: {
223
- suggestion: settled,
224
- outcome: "Written",
225
- object: written.value,
199
+ suggestion: await store.repos.calendarSuggestion.get(
200
+ input.accountConfigId,
201
+ input.suggestion.suggestionId,
202
+ ),
203
+ outcome: "NothingToCancel",
204
+ object: null,
226
205
  },
227
206
  };
207
+ }
208
+
209
+ const collection = await store.repos.calendarCollection.get(
210
+ input.accountConfigId,
211
+ input.calendarId,
212
+ );
213
+ const write = await prepareCalendarObject(collection, {
214
+ accountConfigId: input.accountConfigId,
215
+ calendarId: input.calendarId,
216
+ resourceName:
217
+ existing?.resourceName ?? `${input.suggestion.suggestionId}.ics`,
218
+ icalData: icalData.value,
228
219
  });
220
+ if (!write.ok) return write;
221
+
222
+ const calendarObjectId = deriveCalendarObjectId(
223
+ write.value.object.calendarId,
224
+ write.value.object.resourceName,
225
+ );
226
+ await store.commit([
227
+ write.value,
228
+ {
229
+ kind: "SettleSuggestion",
230
+ accountConfigId: input.accountConfigId,
231
+ suggestionId: input.suggestion.suggestionId,
232
+ settle: {
233
+ state: stateOf[input.answer],
234
+ acceptedCalendarObjectId: calendarObjectId,
235
+ },
236
+ },
237
+ ]);
238
+
239
+ return {
240
+ ok: true,
241
+ value: {
242
+ suggestion: await store.repos.calendarSuggestion.get(
243
+ input.accountConfigId,
244
+ input.suggestion.suggestionId,
245
+ ),
246
+ outcome: "Written",
247
+ object: await readWrittenObject(store, write.value),
248
+ },
249
+ };
229
250
  };
package/src/index.ts CHANGED
@@ -56,9 +56,12 @@ export {
56
56
  export {
57
57
  DEFAULT_CALENDAR_URL_SEGMENT,
58
58
  deleteCalendarObject,
59
+ deleteCalendarObjectWrite,
59
60
  type PutCalendarObjectInput,
61
+ prepareCalendarObject,
60
62
  provisionDefaultCalendar,
61
63
  putCalendarObject,
64
+ readWrittenObject,
62
65
  } from "./put.js";
63
66
  export {
64
67
  answersOvertakenBy,
@@ -4,14 +4,16 @@ import type {
4
4
  CalendarFeedTokenItem,
5
5
  CalendarObjectItem,
6
6
  CalendarOccurrenceInput,
7
+ CalendarRepositories,
7
8
  CalendarSuggestionItem,
9
+ CalendarWrite,
8
10
  CreateCalendarCollectionInput,
9
11
  ICalendarCollectionRepository,
10
12
  ICalendarEventIndexRepository,
11
13
  ICalendarFeedTokenRepository,
12
14
  ICalendarObjectRepository,
15
+ ICalendarStore,
13
16
  ICalendarSuggestionRepository,
14
- ICalendarUnitOfWork,
15
17
  PutCalendarFeedTokenInput,
16
18
  PutCalendarObjectInput as PutCalendarObjectRow,
17
19
  PutCalendarSuggestionInput,
@@ -35,13 +37,11 @@ import { CalendarSuggestionState } from "@remit/domain-enums";
35
37
  export class MissingRow extends NotFoundError {}
36
38
 
37
39
  /**
38
- * A pass-through unit of work over plain maps — the shape the port documents
39
- * for a backend with no cross-entity transaction. It proves the write path
40
- * calls what it should, in the order it should; that the three writes stand or
41
- * fall together is proven against a real transaction, in
40
+ * The calendar store over plain maps. A commit applies its writes in order;
41
+ * that they stand or fall together on a real database is proven in
42
42
  * drizzle-service's calendar-put.sqlite.test.ts.
43
43
  */
44
- export class MemoryCalendarStore implements ICalendarUnitOfWork {
44
+ export class MemoryCalendarStore implements ICalendarStore {
45
45
  readonly collections = new Map<string, CalendarCollectionItem>();
46
46
  readonly objects = new Map<string, CalendarObjectItem>();
47
47
  readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
@@ -384,21 +384,102 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
384
384
  return this.suggestionRepo;
385
385
  }
386
386
 
387
- transaction<T>(
388
- fn: (repos: {
389
- calendarCollection: ICalendarCollectionRepository;
390
- calendarObject: ICalendarObjectRepository;
391
- calendarEventIndex: ICalendarEventIndexRepository;
392
- calendarSuggestion: ICalendarSuggestionRepository;
393
- calendarFeedToken: ICalendarFeedTokenRepository;
394
- }) => Promise<T>,
395
- ): Promise<T> {
396
- return fn({
387
+ get repos(): CalendarRepositories {
388
+ return {
397
389
  calendarCollection: this.collectionRepo,
398
390
  calendarObject: this.objectRepo,
399
391
  calendarEventIndex: this.eventIndexRepo,
400
392
  calendarSuggestion: this.suggestionRepo,
401
393
  calendarFeedToken: this.feedTokenRepo,
402
- });
394
+ };
395
+ }
396
+
397
+ async commit(writes: readonly CalendarWrite[]): Promise<void> {
398
+ for (const write of writes) await this.apply(write);
399
+ }
400
+
401
+ private async apply(write: CalendarWrite): Promise<void> {
402
+ switch (write.kind) {
403
+ case "PutObject": {
404
+ const { calendarId } = write.object;
405
+ const syncSequence = await this.collectionRepo.bumpSyncSequence(
406
+ write.accountConfigId,
407
+ calendarId,
408
+ );
409
+ const object = await this.objectRepo.put({
410
+ ...write.object,
411
+ syncSequence,
412
+ });
413
+ await this.eventIndexRepo.replaceForObject(
414
+ calendarId,
415
+ object.calendarObjectId,
416
+ write.occurrences,
417
+ );
418
+ return;
419
+ }
420
+ case "ReprojectObject": {
421
+ const { calendarId, resourceName } = write.object;
422
+ const stored = await this.objectRepo.findByResourceName(
423
+ calendarId,
424
+ resourceName,
425
+ );
426
+ if (stored === null || stored.etag !== write.readEtag) return;
427
+ await this.apply({
428
+ kind: "PutObject",
429
+ accountConfigId: write.accountConfigId,
430
+ object: write.object,
431
+ occurrences: write.occurrences,
432
+ });
433
+ return;
434
+ }
435
+ case "DeleteObject":
436
+ await this.eventIndexRepo.deleteForObject(
437
+ write.calendarId,
438
+ write.calendarObjectId,
439
+ );
440
+ await this.objectRepo.delete(write.calendarId, write.calendarObjectId);
441
+ await this.collectionRepo.bumpSyncSequence(
442
+ write.accountConfigId,
443
+ write.calendarId,
444
+ );
445
+ return;
446
+ case "SettleSuggestion":
447
+ await this.suggestionRepo.settle(
448
+ write.accountConfigId,
449
+ write.suggestionId,
450
+ write.settle,
451
+ );
452
+ return;
453
+ case "UpdateCollection":
454
+ await this.collectionRepo.update(
455
+ write.accountConfigId,
456
+ write.calendarId,
457
+ write.patch,
458
+ );
459
+ return;
460
+ case "DeleteCollection": {
461
+ for (const object of await this.objectRepo.listByCalendar(
462
+ write.calendarId,
463
+ )) {
464
+ await this.eventIndexRepo.deleteForObject(
465
+ write.calendarId,
466
+ object.calendarObjectId,
467
+ );
468
+ await this.objectRepo.delete(
469
+ write.calendarId,
470
+ object.calendarObjectId,
471
+ );
472
+ }
473
+ await this.feedTokenRepo.delete(
474
+ write.accountConfigId,
475
+ write.calendarId,
476
+ );
477
+ await this.collectionRepo.delete(
478
+ write.accountConfigId,
479
+ write.calendarId,
480
+ );
481
+ return;
482
+ }
483
+ }
403
484
  }
404
485
  }
package/src/put.test.ts CHANGED
@@ -275,14 +275,12 @@ describe("putCalendarObject", () => {
275
275
 
276
276
  it("projects the event in the collection's timezone", async () => {
277
277
  const store = new MemoryCalendarStore();
278
- const collection = await store.transaction((repos) =>
279
- repos.calendarCollection.create({
280
- accountConfigId: ACCOUNT_CONFIG_ID,
281
- urlSegment: "berlin",
282
- displayName: "Berlin",
283
- timezone: "Europe/Berlin",
284
- }),
285
- );
278
+ const collection = await store.repos.calendarCollection.create({
279
+ accountConfigId: ACCOUNT_CONFIG_ID,
280
+ urlSegment: "berlin",
281
+ displayName: "Berlin",
282
+ timezone: "Europe/Berlin",
283
+ });
286
284
 
287
285
  const result = await putCalendarObject(store, {
288
286
  accountConfigId: ACCOUNT_CONFIG_ID,
package/src/put.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type {
2
2
  CalendarCollectionItem,
3
3
  CalendarObjectItem,
4
- ICalendarUnitOfWork,
4
+ DeleteCalendarObjectWrite,
5
+ ICalendarStore,
6
+ PutCalendarObjectWrite,
5
7
  } from "@remit/data-ports";
8
+ import { deriveCalendarObjectId } from "@remit/data-ports/id";
6
9
  import { CalendarSource } from "@remit/domain-enums";
7
10
  import type { CalendarResult } from "./errors.js";
8
11
  import { computeEtag } from "./etag.js";
@@ -23,83 +26,92 @@ export interface PutCalendarObjectInput {
23
26
  }
24
27
 
25
28
  /**
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.
29
+ * Turns one calendar resource into the write that stores it, read in the
30
+ * collection's timezone. Every caller that stores an event goes through here —
31
+ * a REST handler, a DAV PUT, an accepted suggestion, a feed refresh — because
32
+ * the object row, its occurrence rows and the collection's sequence bump are
33
+ * one fact, and a caller that writes any of them alone leaves the store
34
+ * describing a calendar that does not exist.
31
35
  *
32
36
  * The bytes are stored exactly as given: parsing is for validating and
33
37
  * projecting them, never for rewriting them. Refusing the resource is a
34
38
  * returned value — the input is client-supplied, so malformed iCalendar is an
35
39
  * outcome the caller renders, not a fault.
36
40
  */
37
- export const putCalendarObject = async (
38
- unitOfWork: ICalendarUnitOfWork,
41
+ export const prepareCalendarObject = async (
42
+ collection: Pick<CalendarCollectionItem, "timezone">,
39
43
  input: PutCalendarObjectInput,
40
- ): Promise<CalendarResult<CalendarObjectItem>> => {
44
+ ): Promise<CalendarResult<PutCalendarObjectWrite>> => {
41
45
  const parsed = await parseCalendar(input.icalData);
42
46
  if (!parsed.ok) return parsed;
43
47
 
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;
48
+ const projection = projectCalendar(parsed.value, collection.timezone);
49
+ if (!projection.ok) return projection;
52
50
 
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
- });
51
+ const expansion = expandCalendar(parsed.value, collection.timezone);
52
+ return {
53
+ ok: true,
54
+ value: {
55
+ kind: "PutObject",
56
+ accountConfigId: input.accountConfigId,
57
+ object: {
58
+ ...projection.value,
59
+ calendarId: input.calendarId,
60
+ resourceName: input.resourceName,
61
+ icalData: input.icalData,
62
+ etag: computeEtag(input.icalData),
63
+ expandedThrough: expansion.expandedThrough,
64
+ },
65
+ occurrences: expansion.occurrences,
66
+ },
67
+ };
68
+ };
68
69
 
69
- await repos.calendarEventIndex.replaceForObject(
70
- input.calendarId,
71
- object.calendarObjectId,
72
- expansion.occurrences,
73
- );
70
+ /** The resource a committed put wrote, under the id its name derives to. */
71
+ export const readWrittenObject = (
72
+ store: ICalendarStore,
73
+ write: PutCalendarObjectWrite,
74
+ ): Promise<CalendarObjectItem> =>
75
+ store.repos.calendarObject.get(
76
+ write.object.calendarId,
77
+ deriveCalendarObjectId(write.object.calendarId, write.object.resourceName),
78
+ );
74
79
 
75
- return { ok: true, value: object };
76
- });
80
+ /** Writes one calendar resource into its collection. */
81
+ export const putCalendarObject = async (
82
+ store: ICalendarStore,
83
+ input: PutCalendarObjectInput,
84
+ ): Promise<CalendarResult<CalendarObjectItem>> => {
85
+ const collection = await store.repos.calendarCollection.get(
86
+ input.accountConfigId,
87
+ input.calendarId,
88
+ );
89
+ const write = await prepareCalendarObject(collection, input);
90
+ if (!write.ok) return write;
91
+ await store.commit([write.value]);
92
+ return { ok: true, value: await readWrittenObject(store, write.value) };
77
93
  };
78
94
 
79
95
  /**
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.
96
+ * Removes a resource and the occurrences it produced — an object dropped
97
+ * without its occurrence rows leaves a calendar showing events no resource
98
+ * backs, which nothing later can attribute or clean up.
83
99
  */
100
+ export const deleteCalendarObjectWrite = (input: {
101
+ accountConfigId: string;
102
+ calendarId: string;
103
+ calendarObjectId: string;
104
+ }): DeleteCalendarObjectWrite => ({ kind: "DeleteObject", ...input });
105
+
84
106
  export const deleteCalendarObject = async (
85
- unitOfWork: ICalendarUnitOfWork,
107
+ store: ICalendarStore,
86
108
  input: {
87
109
  accountConfigId: string;
88
110
  calendarId: string;
89
111
  calendarObjectId: string;
90
112
  },
91
113
  ): 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
- });
114
+ await store.commit([deleteCalendarObjectWrite(input)]);
103
115
  };
104
116
 
105
117
  /**
@@ -109,15 +121,13 @@ export const deleteCalendarObject = async (
109
121
  * the collection the first one made rather than a rival copy of it.
110
122
  */
111
123
  export const provisionDefaultCalendar = (
112
- unitOfWork: ICalendarUnitOfWork,
124
+ store: ICalendarStore,
113
125
  accountConfigId: string,
114
126
  displayName = "Calendar",
115
127
  ): 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
- );
128
+ store.repos.calendarCollection.create({
129
+ accountConfigId,
130
+ urlSegment: DEFAULT_CALENDAR_URL_SEGMENT,
131
+ displayName,
132
+ source: CalendarSource.Default,
133
+ });
@@ -124,13 +124,11 @@ const answerOf = async (
124
124
  };
125
125
 
126
126
  const secondCalendar = (store: MemoryCalendarStore) =>
127
- store.transaction((repos) =>
128
- repos.calendarCollection.create({
129
- accountConfigId: ACCOUNT_CONFIG_ID,
130
- urlSegment: "personal",
131
- displayName: "Personal",
132
- }),
133
- );
127
+ store.repos.calendarCollection.create({
128
+ accountConfigId: ACCOUNT_CONFIG_ID,
129
+ urlSegment: "personal",
130
+ displayName: "Personal",
131
+ });
134
132
 
135
133
  describe("reopenCalendarSuggestion", () => {
136
134
  for (const answer of [
package/src/reopen.ts CHANGED
@@ -2,10 +2,10 @@ import type { CalendarAnswerOvertaken as CalendarAnswerOvertakenValue } from "@r
2
2
  import type {
3
3
  CalendarCollectionItem,
4
4
  CalendarObjectItem,
5
+ CalendarRepositories,
5
6
  CalendarSuggestionItem,
6
- CalendarUnitOfWorkRepositories,
7
+ ICalendarStore,
7
8
  ICalendarSuggestionRepository,
8
- ICalendarUnitOfWork,
9
9
  } from "@remit/data-ports";
10
10
  import {
11
11
  CalendarAnswerOvertaken,
@@ -13,7 +13,7 @@ import {
13
13
  CalendarSuggestionState,
14
14
  } from "@remit/domain-enums";
15
15
  import { type CalendarResult, calendarFailure } from "./errors.js";
16
- import { deleteCalendarObject } from "./put.js";
16
+ import { deleteCalendarObjectWrite } from "./put.js";
17
17
  import { followingUidPrefix } from "./scope.js";
18
18
 
19
19
  export interface ReopenCalendarSuggestionInput {
@@ -93,7 +93,7 @@ export const answersOvertakenBy = async (
93
93
  };
94
94
 
95
95
  const locateAnswered = async (
96
- repos: CalendarUnitOfWorkRepositories,
96
+ repos: CalendarRepositories,
97
97
  collections: CalendarCollectionItem[],
98
98
  calendarObjectId: string,
99
99
  ): Promise<
@@ -110,7 +110,7 @@ const locateAnswered = async (
110
110
  };
111
111
 
112
112
  const partsOf = async (
113
- repos: CalendarUnitOfWorkRepositories,
113
+ repos: CalendarRepositories,
114
114
  suggestion: CalendarSuggestionItem,
115
115
  collection: CalendarCollectionItem,
116
116
  answered: CalendarObjectItem,
@@ -123,7 +123,7 @@ const partsOf = async (
123
123
  ];
124
124
 
125
125
  export const reopenCalendarSuggestion = async (
126
- unitOfWork: ICalendarUnitOfWork,
126
+ store: ICalendarStore,
127
127
  input: ReopenCalendarSuggestionInput,
128
128
  ): Promise<CalendarResult<CalendarSuggestionItem>> => {
129
129
  const { accountConfigId, suggestion } = input;
@@ -143,48 +143,57 @@ export const reopenCalendarSuggestion = async (
143
143
  );
144
144
  }
145
145
 
146
- return unitOfWork.transaction(async (repos) => {
147
- const holders = othersIn(
148
- await holdersByObject(repos.calendarSuggestion, accountConfigId, [
149
- suggestion,
150
- ]),
146
+ const { repos } = store;
147
+ const holders = othersIn(
148
+ await holdersByObject(repos.calendarSuggestion, accountConfigId, [
151
149
  suggestion,
150
+ ]),
151
+ suggestion,
152
+ );
153
+ if (overtakenOf(holders) === CalendarAnswerOvertaken.Cancellation) {
154
+ return calendarFailure<CalendarSuggestionItem>(
155
+ "AnswerOvertaken",
156
+ "The cancellation of this event was answered into the same event, so this answer can no longer be taken back.",
152
157
  );
153
- if (overtakenOf(holders) === CalendarAnswerOvertaken.Cancellation) {
154
- return calendarFailure<CalendarSuggestionItem>(
155
- "AnswerOvertaken",
156
- "The cancellation of this event was answered into the same event, so this answer can no longer be taken back.",
157
- );
158
- }
158
+ }
159
159
 
160
- const located =
161
- suggestion.acceptedCalendarObjectId === "" || holders.length > 0
162
- ? undefined
163
- : await locateAnswered(
164
- repos,
165
- await repos.calendarCollection.listByAccountConfig(accountConfigId),
166
- suggestion.acceptedCalendarObjectId,
167
- );
168
- if (located) {
169
- for (const object of await partsOf(
170
- repos,
171
- suggestion,
172
- located.collection,
173
- located.object,
174
- )) {
175
- await deleteCalendarObject(unitOfWork, {
160
+ const located =
161
+ suggestion.acceptedCalendarObjectId === "" || holders.length > 0
162
+ ? undefined
163
+ : await locateAnswered(
164
+ repos,
165
+ await repos.calendarCollection.listByAccountConfig(accountConfigId),
166
+ suggestion.acceptedCalendarObjectId,
167
+ );
168
+ const removals = located
169
+ ? (
170
+ await partsOf(repos, suggestion, located.collection, located.object)
171
+ ).map((object) =>
172
+ deleteCalendarObjectWrite({
176
173
  accountConfigId,
177
174
  calendarId: located.collection.calendarId,
178
175
  calendarObjectId: object.calendarObjectId,
179
- });
180
- }
181
- }
176
+ }),
177
+ )
178
+ : [];
182
179
 
183
- const settled = await repos.calendarSuggestion.settle(
180
+ await store.commit([
181
+ ...removals,
182
+ {
183
+ kind: "SettleSuggestion",
184
+ accountConfigId,
185
+ suggestionId: suggestion.suggestionId,
186
+ settle: {
187
+ state: CalendarSuggestionState.Pending,
188
+ acceptedCalendarObjectId: "",
189
+ },
190
+ },
191
+ ]);
192
+ return {
193
+ ok: true,
194
+ value: await repos.calendarSuggestion.get(
184
195
  accountConfigId,
185
196
  suggestion.suggestionId,
186
- { state: CalendarSuggestionState.Pending, acceptedCalendarObjectId: "" },
187
- );
188
- return { ok: true, value: settled };
189
- });
197
+ ),
198
+ };
190
199
  };
@@ -39,16 +39,14 @@ const subscribed = async (): Promise<{
39
39
  collection: CalendarCollectionItem;
40
40
  }> => {
41
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
- );
42
+ const collection = await store.repos.calendarCollection.create({
43
+ accountConfigId: ACCOUNT_CONFIG_ID,
44
+ urlSegment: "harbour-rota",
45
+ displayName: "Harbour rota",
46
+ source: CalendarSource.Subscribed,
47
+ subscriptionUrl: SECRET_URL,
48
+ subscriptionEnabled: true,
49
+ });
52
50
  return { store, collection };
53
51
  };
54
52
 
@@ -56,12 +54,11 @@ const drawn = async (
56
54
  store: MemoryCalendarStore,
57
55
  collection: CalendarCollectionItem,
58
56
  ) => {
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),
57
+ const current = await store.repos.calendarCollection.get(
58
+ ACCOUNT_CONFIG_ID,
59
+ collection.calendarId,
64
60
  );
61
+ const instances = await listCalendarInstances(store.repos, [current], WINDOW);
65
62
  return instances.map((instance) => ({
66
63
  summary: instance.summary,
67
64
  startAt: instance.startAt,
@@ -352,8 +349,9 @@ describe("writeCalendarFeed", () => {
352
349
  },
353
350
  ]);
354
351
 
355
- const stored = await store.transaction((repos) =>
356
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
352
+ const stored = await store.repos.calendarCollection.get(
353
+ ACCOUNT_CONFIG_ID,
354
+ collection.calendarId,
357
355
  );
358
356
  assert.equal(stored.subscriptionCheckedAt, 5_000);
359
357
  assert.equal(stored.subscriptionFetchedAt, 5_000);
@@ -364,8 +362,9 @@ describe("writeCalendarFeed", () => {
364
362
  const { store, collection } = await subscribed();
365
363
  await writeFeed(store, collection, GOOGLE_FEED, 5_000);
366
364
  const sequenceBefore = (
367
- await store.transaction((repos) =>
368
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
365
+ await store.repos.calendarCollection.get(
366
+ ACCOUNT_CONFIG_ID,
367
+ collection.calendarId,
369
368
  )
370
369
  ).syncSequence;
371
370
 
@@ -387,8 +386,9 @@ describe("writeCalendarFeed", () => {
387
386
  );
388
387
  assert.equal(summaries.includes("Tide table review"), false);
389
388
  assert.equal(summaries.length, 6);
390
- const after = await store.transaction((repos) =>
391
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
389
+ const after = await store.repos.calendarCollection.get(
390
+ ACCOUNT_CONFIG_ID,
391
+ collection.calendarId,
392
392
  );
393
393
  assert.equal(after.syncSequence, sequenceBefore + 1);
394
394
  });
@@ -396,8 +396,9 @@ describe("writeCalendarFeed", () => {
396
396
  it("writes nothing when a second fetch differs only in its export stamps", async () => {
397
397
  const { store, collection } = await subscribed();
398
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),
399
+ const before = await store.repos.calendarCollection.get(
400
+ ACCOUNT_CONFIG_ID,
401
+ collection.calendarId,
401
402
  );
402
403
 
403
404
  const restamped = GOOGLE_FEED.replaceAll(
@@ -413,8 +414,9 @@ describe("writeCalendarFeed", () => {
413
414
  removed: 0,
414
415
  skipped: 0,
415
416
  });
416
- const after = await store.transaction((repos) =>
417
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
417
+ const after = await store.repos.calendarCollection.get(
418
+ ACCOUNT_CONFIG_ID,
419
+ collection.calendarId,
418
420
  );
419
421
  assert.equal(after.syncSequence, before.syncSequence);
420
422
  assert.equal(after.subscriptionFetchedAt, 9_000);
@@ -422,12 +424,10 @@ describe("writeCalendarFeed", () => {
422
424
 
423
425
  it("writes nothing for a subscription paused while its feed was read", async () => {
424
426
  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
- ),
427
+ await store.repos.calendarCollection.update(
428
+ ACCOUNT_CONFIG_ID,
429
+ collection.calendarId,
430
+ { subscriptionEnabled: false },
431
431
  );
432
432
 
433
433
  const write = await writeFeed(store, collection, GOOGLE_FEED, 5_000);
@@ -440,8 +440,9 @@ describe("writeCalendarFeed", () => {
440
440
  skipped: 0,
441
441
  });
442
442
  assert.deepEqual(await drawn(store, collection), []);
443
- const stored = await store.transaction((repos) =>
444
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
443
+ const stored = await store.repos.calendarCollection.get(
444
+ ACCOUNT_CONFIG_ID,
445
+ collection.calendarId,
445
446
  );
446
447
  assert.equal(stored.subscriptionFetchedAt, 0);
447
448
  });
@@ -479,8 +480,9 @@ describe("writeCalendarFeed", () => {
479
480
 
480
481
  assert.equal(write.written, 1);
481
482
  assert.equal(write.skipped, 2);
482
- const stored = await store.transaction((repos) =>
483
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
483
+ const stored = await store.repos.calendarCollection.get(
484
+ ACCOUNT_CONFIG_ID,
485
+ collection.calendarId,
484
486
  );
485
487
  assert.equal(
486
488
  stored.subscriptionError,
@@ -509,8 +511,9 @@ describe("refreshCalendarSubscription", () => {
509
511
  ok: false,
510
512
  reason: "the feed answered HTTP 503",
511
513
  });
512
- const stored = await store.transaction((repos) =>
513
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
514
+ const stored = await store.repos.calendarCollection.get(
515
+ ACCOUNT_CONFIG_ID,
516
+ collection.calendarId,
514
517
  );
515
518
  assert.equal(stored.subscriptionCheckedAt, 9_000);
516
519
  assert.equal(stored.subscriptionFetchedAt, 5_000);
@@ -557,8 +560,9 @@ describe("refreshCalendarSubscription", () => {
557
560
 
558
561
  assert.deepEqual(asked, [SECRET_URL]);
559
562
  assert.ok(refreshed.ok);
560
- const stored = await store.transaction((repos) =>
561
- repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
563
+ const stored = await store.repos.calendarCollection.get(
564
+ ACCOUNT_CONFIG_ID,
565
+ collection.calendarId,
562
566
  );
563
567
  assert.equal(stored.subscriptionError, "");
564
568
  assert.equal(stored.subscriptionFetchedAt, 6_000);
package/src/subscribe.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type {
3
3
  CalendarCollectionItem,
4
- ICalendarUnitOfWork,
4
+ CalendarWrite,
5
+ ICalendarStore,
5
6
  } from "@remit/data-ports";
6
7
  import ICAL from "ical.js";
7
8
  import { type CalendarResult, calendarFailure } from "./errors.js";
8
9
  import { readCalendarComponent, serializeCalendar } from "./parse.js";
9
- import { deleteCalendarObject, putCalendarObject } from "./put.js";
10
+ import { deleteCalendarObjectWrite, prepareCalendarObject } from "./put.js";
10
11
 
11
12
  export const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30_000;
12
13
 
@@ -271,99 +272,111 @@ const feedErrorOf = (write: CalendarFeedWrite): string =>
271
272
  : `${write.skipped} ${write.skipped === 1 ? "event" : "events"} in the feed could not be stored`;
272
273
 
273
274
  /**
274
- * Makes a subscribed collection hold what the feed holds, in one unit.
275
+ * Makes a subscribed collection hold what the feed holds, in one commit.
275
276
  *
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
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
278
279
  * is. A resource whose bytes have not changed is left alone, and one the feed
279
280
  * no longer carries is removed. A resource the write path refuses is counted
280
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.
281
285
  */
282
286
  export const writeCalendarFeed = async (
283
- unitOfWork: ICalendarUnitOfWork,
287
+ store: ICalendarStore,
284
288
  target: CalendarSubscriptionTarget,
285
289
  split: CalendarFeedSplit,
286
290
  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
- };
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
+ };
308
314
 
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
- }
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
+ ) {
329
321
  kept.add(resource.resourceName);
330
- write.written += 1;
322
+ write.unchanged += 1;
323
+ continue;
331
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
+ }
332
339
 
333
- for (const object of stored) {
334
- if (kept.has(object.resourceName)) continue;
335
- await deleteCalendarObject(unitOfWork, {
340
+ for (const object of stored) {
341
+ if (kept.has(object.resourceName)) continue;
342
+ writes.push(
343
+ deleteCalendarObjectWrite({
336
344
  accountConfigId: target.accountConfigId,
337
345
  calendarId: target.calendarId,
338
346
  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
- },
347
+ }),
351
348
  );
352
- return write;
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
+ },
353
361
  });
362
+ await store.commit(writes);
363
+ return write;
364
+ };
354
365
 
355
366
  /** Records a refresh that stored nothing, and why. Every stored event stays. */
356
367
  export const recordCalendarFeedFailure = async (
357
- unitOfWork: ICalendarUnitOfWork,
368
+ store: ICalendarStore,
358
369
  target: CalendarSubscriptionTarget,
359
370
  reason: string,
360
371
  now: number,
361
372
  ): Promise<void> => {
362
- await unitOfWork.transaction((repos) =>
363
- repos.calendarCollection.update(target.accountConfigId, target.calendarId, {
373
+ await store.repos.calendarCollection.update(
374
+ target.accountConfigId,
375
+ target.calendarId,
376
+ {
364
377
  subscriptionCheckedAt: now,
365
378
  subscriptionError: truncate(reason),
366
- }),
379
+ },
367
380
  );
368
381
  };
369
382
 
@@ -391,7 +404,7 @@ export const readCalendarFeed = async (
391
404
  * records its reason on the collection and removes nothing.
392
405
  */
393
406
  export const refreshCalendarSubscription = async (
394
- unitOfWork: ICalendarUnitOfWork,
407
+ store: ICalendarStore,
395
408
  collection: CalendarCollectionItem,
396
409
  now: number,
397
410
  fetcher: CalendarFeedFetcher = fetch,
@@ -402,10 +415,10 @@ export const refreshCalendarSubscription = async (
402
415
  };
403
416
  const feed = await readCalendarFeed(collection.subscriptionUrl, fetcher);
404
417
  if (!feed.ok) {
405
- await recordCalendarFeedFailure(unitOfWork, target, feed.reason, now);
418
+ await recordCalendarFeedFailure(store, target, feed.reason, now);
406
419
  return feed;
407
420
  }
408
- const write = await writeCalendarFeed(unitOfWork, target, feed.value, now);
421
+ const write = await writeCalendarFeed(store, target, feed.value, now);
409
422
  return { ok: true, value: write };
410
423
  };
411
424