@remit/calendar-service 0.0.1 → 0.0.3
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 +2 -2
- package/src/accept.test.ts +357 -0
- package/src/accept.ts +207 -0
- package/src/build.test.ts +113 -0
- package/src/build.ts +311 -0
- package/src/errors.ts +13 -1
- package/src/expand.test.ts +10 -0
- package/src/expand.ts +221 -111
- package/src/index.ts +53 -1
- package/src/memory-store.ts +303 -0
- package/src/parse.test.ts +11 -0
- package/src/parse.ts +7 -1
- package/src/project.ts +26 -8
- package/src/put.test.ts +1 -193
- package/src/scope.test.ts +472 -0
- package/src/scope.ts +490 -0
- package/src/suggest.test.ts +329 -0
- package/src/suggest.ts +199 -0
- package/src/time.ts +66 -0
- package/src/window.test.ts +524 -0
- package/src/window.ts +232 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarCollectionItem,
|
|
3
|
+
CalendarEventIndexItem,
|
|
4
|
+
CalendarObjectItem,
|
|
5
|
+
CalendarOccurrenceInput,
|
|
6
|
+
CalendarSuggestionItem,
|
|
7
|
+
CreateCalendarCollectionInput,
|
|
8
|
+
ICalendarCollectionRepository,
|
|
9
|
+
ICalendarEventIndexRepository,
|
|
10
|
+
ICalendarObjectRepository,
|
|
11
|
+
ICalendarSuggestionRepository,
|
|
12
|
+
ICalendarUnitOfWork,
|
|
13
|
+
PutCalendarObjectInput as PutCalendarObjectRow,
|
|
14
|
+
PutCalendarSuggestionInput,
|
|
15
|
+
SettleCalendarSuggestionInput,
|
|
16
|
+
UpdateCalendarCollectionInput,
|
|
17
|
+
} from "@remit/data-ports";
|
|
18
|
+
import {
|
|
19
|
+
deriveCalendarId,
|
|
20
|
+
deriveCalendarObjectId,
|
|
21
|
+
deriveCalendarSuggestionId,
|
|
22
|
+
normalizeCalendarUrlSegment,
|
|
23
|
+
} from "@remit/data-ports/id";
|
|
24
|
+
import { CalendarSuggestionState } from "@remit/domain-enums";
|
|
25
|
+
|
|
26
|
+
export class MissingRow extends Error {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A pass-through unit of work over plain maps — the shape the port documents
|
|
30
|
+
* for a backend with no cross-entity transaction. It proves the write path
|
|
31
|
+
* calls what it should, in the order it should; that the three writes stand or
|
|
32
|
+
* fall together is proven against a real transaction, in
|
|
33
|
+
* drizzle-service's calendar-put.sqlite.test.ts.
|
|
34
|
+
*/
|
|
35
|
+
export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
36
|
+
readonly collections = new Map<string, CalendarCollectionItem>();
|
|
37
|
+
readonly objects = new Map<string, CalendarObjectItem>();
|
|
38
|
+
readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
|
|
39
|
+
readonly suggestions = new Map<string, CalendarSuggestionItem>();
|
|
40
|
+
|
|
41
|
+
private readonly collectionRepo: ICalendarCollectionRepository = {
|
|
42
|
+
create: async (input: CreateCalendarCollectionInput) => {
|
|
43
|
+
const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
|
|
44
|
+
const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
|
|
45
|
+
const existing = this.collections.get(calendarId);
|
|
46
|
+
if (existing) return existing;
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const collection: CalendarCollectionItem = {
|
|
49
|
+
calendarId,
|
|
50
|
+
accountConfigId: input.accountConfigId,
|
|
51
|
+
urlSegment,
|
|
52
|
+
displayName: input.displayName,
|
|
53
|
+
color: input.color ?? "Cal1",
|
|
54
|
+
componentSet: input.componentSet ?? "VeventOnly",
|
|
55
|
+
source: input.source ?? "UserCreated",
|
|
56
|
+
timezone: input.timezone ?? "",
|
|
57
|
+
syncSequence: 0,
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
};
|
|
61
|
+
this.collections.set(calendarId, collection);
|
|
62
|
+
return collection;
|
|
63
|
+
},
|
|
64
|
+
get: async (_accountConfigId: string, calendarId: string) => {
|
|
65
|
+
const collection = this.collections.get(calendarId);
|
|
66
|
+
if (!collection) throw new MissingRow(calendarId);
|
|
67
|
+
return collection;
|
|
68
|
+
},
|
|
69
|
+
update: async (
|
|
70
|
+
_accountConfigId: string,
|
|
71
|
+
calendarId: string,
|
|
72
|
+
input: UpdateCalendarCollectionInput,
|
|
73
|
+
) => {
|
|
74
|
+
const collection = this.collections.get(calendarId);
|
|
75
|
+
if (!collection) throw new MissingRow(calendarId);
|
|
76
|
+
const updated = { ...collection, ...input };
|
|
77
|
+
this.collections.set(calendarId, updated);
|
|
78
|
+
return updated;
|
|
79
|
+
},
|
|
80
|
+
delete: async (_accountConfigId: string, calendarId: string) => {
|
|
81
|
+
this.collections.delete(calendarId);
|
|
82
|
+
},
|
|
83
|
+
listByAccountConfig: async (accountConfigId: string) =>
|
|
84
|
+
[...this.collections.values()].filter(
|
|
85
|
+
(collection) => collection.accountConfigId === accountConfigId,
|
|
86
|
+
),
|
|
87
|
+
createExclusive: async (input: CreateCalendarCollectionInput) => {
|
|
88
|
+
const calendarId = deriveCalendarId(
|
|
89
|
+
input.accountConfigId,
|
|
90
|
+
input.urlSegment,
|
|
91
|
+
);
|
|
92
|
+
if (this.collections.has(calendarId)) return null;
|
|
93
|
+
return this.collectionRepo.create(input);
|
|
94
|
+
},
|
|
95
|
+
findByUrlSegment: async (accountConfigId: string, urlSegment: string) =>
|
|
96
|
+
this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
|
|
97
|
+
null,
|
|
98
|
+
bumpSyncSequence: async (_accountConfigId: string, calendarId: string) => {
|
|
99
|
+
const collection = this.collections.get(calendarId);
|
|
100
|
+
if (!collection) throw new MissingRow(calendarId);
|
|
101
|
+
const bumped = {
|
|
102
|
+
...collection,
|
|
103
|
+
syncSequence: collection.syncSequence + 1,
|
|
104
|
+
};
|
|
105
|
+
this.collections.set(calendarId, bumped);
|
|
106
|
+
return bumped.syncSequence;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
private readonly objectRepo: ICalendarObjectRepository = {
|
|
111
|
+
put: async (input: PutCalendarObjectRow) => {
|
|
112
|
+
const calendarObjectId = deriveCalendarObjectId(
|
|
113
|
+
input.calendarId,
|
|
114
|
+
input.resourceName,
|
|
115
|
+
);
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const object: CalendarObjectItem = {
|
|
118
|
+
...input,
|
|
119
|
+
calendarObjectId,
|
|
120
|
+
createdAt: this.objects.get(calendarObjectId)?.createdAt ?? now,
|
|
121
|
+
updatedAt: now,
|
|
122
|
+
};
|
|
123
|
+
this.objects.set(calendarObjectId, object);
|
|
124
|
+
return object;
|
|
125
|
+
},
|
|
126
|
+
listIncompleteExpansions: async (calendarId: string, instant: string) =>
|
|
127
|
+
[...this.objects.values()].filter(
|
|
128
|
+
(object) =>
|
|
129
|
+
object.calendarId === calendarId &&
|
|
130
|
+
object.expandedThrough !== "" &&
|
|
131
|
+
object.expandedThrough < instant,
|
|
132
|
+
),
|
|
133
|
+
find: async (_calendarId: string, calendarObjectId: string) =>
|
|
134
|
+
this.objects.get(calendarObjectId) ?? null,
|
|
135
|
+
get: async (_calendarId: string, calendarObjectId: string) => {
|
|
136
|
+
const object = this.objects.get(calendarObjectId);
|
|
137
|
+
if (!object) throw new MissingRow(calendarObjectId);
|
|
138
|
+
return object;
|
|
139
|
+
},
|
|
140
|
+
delete: async (_calendarId: string, calendarObjectId: string) => {
|
|
141
|
+
this.objects.delete(calendarObjectId);
|
|
142
|
+
},
|
|
143
|
+
findByResourceName: async (calendarId: string, resourceName: string) =>
|
|
144
|
+
this.objects.get(deriveCalendarObjectId(calendarId, resourceName)) ??
|
|
145
|
+
null,
|
|
146
|
+
findByUid: async (calendarId: string, icalUid: string) =>
|
|
147
|
+
[...this.objects.values()].find(
|
|
148
|
+
(object) =>
|
|
149
|
+
object.calendarId === calendarId && object.icalUid === icalUid,
|
|
150
|
+
) ?? null,
|
|
151
|
+
listByCalendar: async (calendarId: string) =>
|
|
152
|
+
[...this.objects.values()].filter(
|
|
153
|
+
(object) => object.calendarId === calendarId,
|
|
154
|
+
),
|
|
155
|
+
listChangedSince: async (calendarId: string, syncSequence: number) =>
|
|
156
|
+
[...this.objects.values()]
|
|
157
|
+
.filter(
|
|
158
|
+
(object) =>
|
|
159
|
+
object.calendarId === calendarId &&
|
|
160
|
+
object.syncSequence > syncSequence,
|
|
161
|
+
)
|
|
162
|
+
.sort((left, right) => left.syncSequence - right.syncSequence),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
private readonly eventIndexRepo: ICalendarEventIndexRepository = {
|
|
166
|
+
replaceForObject: async (
|
|
167
|
+
calendarId: string,
|
|
168
|
+
calendarObjectId: string,
|
|
169
|
+
occurrences: CalendarOccurrenceInput[],
|
|
170
|
+
) => {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
this.occurrences.set(
|
|
173
|
+
calendarObjectId,
|
|
174
|
+
occurrences.map((occurrence) => ({
|
|
175
|
+
...occurrence,
|
|
176
|
+
calendarId,
|
|
177
|
+
calendarObjectId,
|
|
178
|
+
createdAt: now,
|
|
179
|
+
updatedAt: now,
|
|
180
|
+
})),
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
deleteForObject: async (_calendarId: string, calendarObjectId: string) => {
|
|
184
|
+
this.occurrences.delete(calendarObjectId);
|
|
185
|
+
},
|
|
186
|
+
listForObject: async (_calendarId: string, calendarObjectId: string) =>
|
|
187
|
+
this.occurrences.get(calendarObjectId) ?? [],
|
|
188
|
+
listByStartRange: async (
|
|
189
|
+
calendarId: string,
|
|
190
|
+
startAt: string,
|
|
191
|
+
endAt: string,
|
|
192
|
+
) =>
|
|
193
|
+
[...this.occurrences.values()]
|
|
194
|
+
.flat()
|
|
195
|
+
.filter(
|
|
196
|
+
(row) =>
|
|
197
|
+
row.calendarId === calendarId &&
|
|
198
|
+
row.startAt >= startAt &&
|
|
199
|
+
row.startAt < endAt,
|
|
200
|
+
)
|
|
201
|
+
.sort((left, right) => left.startAt.localeCompare(right.startAt)),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
private readonly suggestionRepo: ICalendarSuggestionRepository = {
|
|
205
|
+
put: async (input: PutCalendarSuggestionInput) => {
|
|
206
|
+
const suggestionId = deriveCalendarSuggestionId(
|
|
207
|
+
input.messageId,
|
|
208
|
+
input.bodyPartId,
|
|
209
|
+
input.icalUid,
|
|
210
|
+
);
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
const existing = this.suggestions.get(suggestionId);
|
|
213
|
+
const suggestion: CalendarSuggestionItem = {
|
|
214
|
+
...input,
|
|
215
|
+
suggestionId,
|
|
216
|
+
state: existing?.state ?? CalendarSuggestionState.Pending,
|
|
217
|
+
acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
|
|
218
|
+
createdAt: existing?.createdAt ?? now,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
};
|
|
221
|
+
this.suggestions.set(suggestionId, suggestion);
|
|
222
|
+
return suggestion;
|
|
223
|
+
},
|
|
224
|
+
get: async (accountConfigId: string, suggestionId: string) => {
|
|
225
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
226
|
+
if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
|
|
227
|
+
throw new MissingRow(suggestionId);
|
|
228
|
+
}
|
|
229
|
+
return suggestion;
|
|
230
|
+
},
|
|
231
|
+
listByMessage: async (accountConfigId: string, messageId: string) =>
|
|
232
|
+
[...this.suggestions.values()].filter(
|
|
233
|
+
(suggestion) =>
|
|
234
|
+
suggestion.accountConfigId === accountConfigId &&
|
|
235
|
+
suggestion.messageId === messageId,
|
|
236
|
+
),
|
|
237
|
+
listByState: async (
|
|
238
|
+
accountConfigId: string,
|
|
239
|
+
state: CalendarSuggestionItem["state"],
|
|
240
|
+
) => ({
|
|
241
|
+
items: [...this.suggestions.values()].filter(
|
|
242
|
+
(suggestion) =>
|
|
243
|
+
suggestion.accountConfigId === accountConfigId &&
|
|
244
|
+
suggestion.state === state,
|
|
245
|
+
),
|
|
246
|
+
continuationToken: undefined,
|
|
247
|
+
}),
|
|
248
|
+
settle: async (
|
|
249
|
+
accountConfigId: string,
|
|
250
|
+
suggestionId: string,
|
|
251
|
+
input: SettleCalendarSuggestionInput,
|
|
252
|
+
) => {
|
|
253
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
254
|
+
if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
|
|
255
|
+
throw new MissingRow(suggestionId);
|
|
256
|
+
}
|
|
257
|
+
const settled = { ...suggestion, ...input, updatedAt: Date.now() };
|
|
258
|
+
this.suggestions.set(suggestionId, settled);
|
|
259
|
+
return settled;
|
|
260
|
+
},
|
|
261
|
+
supersedeIfPending: async (
|
|
262
|
+
accountConfigId: string,
|
|
263
|
+
suggestionId: string,
|
|
264
|
+
) => {
|
|
265
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
266
|
+
if (
|
|
267
|
+
!suggestion ||
|
|
268
|
+
suggestion.accountConfigId !== accountConfigId ||
|
|
269
|
+
suggestion.state !== CalendarSuggestionState.Pending
|
|
270
|
+
) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const retired = {
|
|
274
|
+
...suggestion,
|
|
275
|
+
state: CalendarSuggestionState.Superseded,
|
|
276
|
+
acceptedCalendarObjectId: "",
|
|
277
|
+
updatedAt: Date.now(),
|
|
278
|
+
};
|
|
279
|
+
this.suggestions.set(suggestionId, retired);
|
|
280
|
+
return retired;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
get calendarSuggestion(): ICalendarSuggestionRepository {
|
|
285
|
+
return this.suggestionRepo;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
transaction<T>(
|
|
289
|
+
fn: (repos: {
|
|
290
|
+
calendarCollection: ICalendarCollectionRepository;
|
|
291
|
+
calendarObject: ICalendarObjectRepository;
|
|
292
|
+
calendarEventIndex: ICalendarEventIndexRepository;
|
|
293
|
+
calendarSuggestion: ICalendarSuggestionRepository;
|
|
294
|
+
}) => Promise<T>,
|
|
295
|
+
): Promise<T> {
|
|
296
|
+
return fn({
|
|
297
|
+
calendarCollection: this.collectionRepo,
|
|
298
|
+
calendarObject: this.objectRepo,
|
|
299
|
+
calendarEventIndex: this.eventIndexRepo,
|
|
300
|
+
calendarSuggestion: this.suggestionRepo,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/parse.test.ts
CHANGED
|
@@ -56,6 +56,17 @@ describe("parseCalendar", () => {
|
|
|
56
56
|
);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it("refuses empty bytes as a value, naming nothing rather than crashing", async () => {
|
|
60
|
+
// A suggestion read out of prose carries no iCalendar at all, and the
|
|
61
|
+
// accept path reaches this with `""`. ical.js reads empty bytes as a
|
|
62
|
+
// component with no name, so naming what arrived would throw here — a
|
|
63
|
+
// crash on ordinary input rather than the refusal every caller renders.
|
|
64
|
+
const refusal = await refusalCode("");
|
|
65
|
+
|
|
66
|
+
assert.equal(refusal.code, "NotACalendar");
|
|
67
|
+
assert.match(refusal.message, /found nothing/);
|
|
68
|
+
});
|
|
69
|
+
|
|
59
70
|
it("refuses a document whose root is not a VCALENDAR", async () => {
|
|
60
71
|
assert.equal(
|
|
61
72
|
(
|
package/src/parse.ts
CHANGED
|
@@ -64,9 +64,15 @@ export const parseCalendar = async (
|
|
|
64
64
|
const component = read.value;
|
|
65
65
|
|
|
66
66
|
if (component.name !== "vcalendar") {
|
|
67
|
+
// A document with nothing in it names nothing: ical.js reads empty bytes
|
|
68
|
+
// as a component with no name at all, and a suggestion read out of prose
|
|
69
|
+
// carries exactly that. Naming what arrived is for a document that has a
|
|
70
|
+
// root to name.
|
|
67
71
|
return calendarFailure(
|
|
68
72
|
"NotACalendar",
|
|
69
|
-
|
|
73
|
+
component.name
|
|
74
|
+
? `expected a VCALENDAR, found ${component.name.toUpperCase()}`
|
|
75
|
+
: "expected a VCALENDAR, found nothing",
|
|
70
76
|
);
|
|
71
77
|
}
|
|
72
78
|
|
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
|
-
|
|
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
|
@@ -1,25 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
CalendarCollectionItem,
|
|
5
|
-
CalendarEventIndexItem,
|
|
6
|
-
CalendarObjectItem,
|
|
7
|
-
CalendarOccurrenceInput,
|
|
8
|
-
CreateCalendarCollectionInput,
|
|
9
|
-
ICalendarCollectionRepository,
|
|
10
|
-
ICalendarEventIndexRepository,
|
|
11
|
-
ICalendarObjectRepository,
|
|
12
|
-
ICalendarUnitOfWork,
|
|
13
|
-
PutCalendarObjectInput as PutCalendarObjectRow,
|
|
14
|
-
UpdateCalendarCollectionInput,
|
|
15
|
-
} from "@remit/data-ports";
|
|
16
|
-
import {
|
|
17
|
-
deriveCalendarId,
|
|
18
|
-
deriveCalendarObjectId,
|
|
19
|
-
normalizeCalendarUrlSegment,
|
|
20
|
-
} from "@remit/data-ports/id";
|
|
21
3
|
import { computeEtag } from "./etag.js";
|
|
22
4
|
import { asLf, singleEvent } from "./fixtures.js";
|
|
5
|
+
import { MemoryCalendarStore, MissingRow } from "./memory-store.js";
|
|
23
6
|
import { parseCalendar, serializeCalendar } from "./parse.js";
|
|
24
7
|
import {
|
|
25
8
|
DEFAULT_CALENDAR_URL_SEGMENT,
|
|
@@ -28,181 +11,6 @@ import {
|
|
|
28
11
|
putCalendarObject,
|
|
29
12
|
} from "./put.js";
|
|
30
13
|
|
|
31
|
-
class MissingRow extends Error {}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* A pass-through unit of work over plain maps — the shape the port documents
|
|
35
|
-
* for a backend with no cross-entity transaction. It proves the write path
|
|
36
|
-
* calls what it should, in the order it should; that the three writes stand or
|
|
37
|
-
* fall together is proven against a real transaction, in
|
|
38
|
-
* drizzle-service's calendar-put.sqlite.test.ts.
|
|
39
|
-
*/
|
|
40
|
-
class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
41
|
-
readonly collections = new Map<string, CalendarCollectionItem>();
|
|
42
|
-
readonly objects = new Map<string, CalendarObjectItem>();
|
|
43
|
-
readonly occurrences = new Map<string, CalendarEventIndexItem[]>();
|
|
44
|
-
|
|
45
|
-
private readonly collectionRepo: ICalendarCollectionRepository = {
|
|
46
|
-
create: async (input: CreateCalendarCollectionInput) => {
|
|
47
|
-
const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
|
|
48
|
-
const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
|
|
49
|
-
const existing = this.collections.get(calendarId);
|
|
50
|
-
if (existing) return existing;
|
|
51
|
-
const now = Date.now();
|
|
52
|
-
const collection: CalendarCollectionItem = {
|
|
53
|
-
calendarId,
|
|
54
|
-
accountConfigId: input.accountConfigId,
|
|
55
|
-
urlSegment,
|
|
56
|
-
displayName: input.displayName,
|
|
57
|
-
color: input.color ?? "Cal1",
|
|
58
|
-
componentSet: input.componentSet ?? "VeventOnly",
|
|
59
|
-
source: input.source ?? "UserCreated",
|
|
60
|
-
timezone: input.timezone ?? "",
|
|
61
|
-
syncSequence: 0,
|
|
62
|
-
createdAt: now,
|
|
63
|
-
updatedAt: now,
|
|
64
|
-
};
|
|
65
|
-
this.collections.set(calendarId, collection);
|
|
66
|
-
return collection;
|
|
67
|
-
},
|
|
68
|
-
get: async (_accountConfigId: string, calendarId: string) => {
|
|
69
|
-
const collection = this.collections.get(calendarId);
|
|
70
|
-
if (!collection) throw new MissingRow(calendarId);
|
|
71
|
-
return collection;
|
|
72
|
-
},
|
|
73
|
-
update: async (
|
|
74
|
-
_accountConfigId: string,
|
|
75
|
-
calendarId: string,
|
|
76
|
-
input: UpdateCalendarCollectionInput,
|
|
77
|
-
) => {
|
|
78
|
-
const collection = this.collections.get(calendarId);
|
|
79
|
-
if (!collection) throw new MissingRow(calendarId);
|
|
80
|
-
const updated = { ...collection, ...input };
|
|
81
|
-
this.collections.set(calendarId, updated);
|
|
82
|
-
return updated;
|
|
83
|
-
},
|
|
84
|
-
delete: async (_accountConfigId: string, calendarId: string) => {
|
|
85
|
-
this.collections.delete(calendarId);
|
|
86
|
-
},
|
|
87
|
-
listByAccountConfig: async (accountConfigId: string) =>
|
|
88
|
-
[...this.collections.values()].filter(
|
|
89
|
-
(collection) => collection.accountConfigId === accountConfigId,
|
|
90
|
-
),
|
|
91
|
-
findByUrlSegment: async (accountConfigId: string, urlSegment: string) =>
|
|
92
|
-
this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
|
|
93
|
-
null,
|
|
94
|
-
bumpSyncSequence: async (_accountConfigId: string, calendarId: string) => {
|
|
95
|
-
const collection = this.collections.get(calendarId);
|
|
96
|
-
if (!collection) throw new MissingRow(calendarId);
|
|
97
|
-
const bumped = {
|
|
98
|
-
...collection,
|
|
99
|
-
syncSequence: collection.syncSequence + 1,
|
|
100
|
-
};
|
|
101
|
-
this.collections.set(calendarId, bumped);
|
|
102
|
-
return bumped.syncSequence;
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
private readonly objectRepo: ICalendarObjectRepository = {
|
|
107
|
-
put: async (input: PutCalendarObjectRow) => {
|
|
108
|
-
const calendarObjectId = deriveCalendarObjectId(
|
|
109
|
-
input.calendarId,
|
|
110
|
-
input.resourceName,
|
|
111
|
-
);
|
|
112
|
-
const now = Date.now();
|
|
113
|
-
const object: CalendarObjectItem = {
|
|
114
|
-
...input,
|
|
115
|
-
calendarObjectId,
|
|
116
|
-
createdAt: this.objects.get(calendarObjectId)?.createdAt ?? now,
|
|
117
|
-
updatedAt: now,
|
|
118
|
-
};
|
|
119
|
-
this.objects.set(calendarObjectId, object);
|
|
120
|
-
return object;
|
|
121
|
-
},
|
|
122
|
-
get: async (_calendarId: string, calendarObjectId: string) => {
|
|
123
|
-
const object = this.objects.get(calendarObjectId);
|
|
124
|
-
if (!object) throw new MissingRow(calendarObjectId);
|
|
125
|
-
return object;
|
|
126
|
-
},
|
|
127
|
-
delete: async (_calendarId: string, calendarObjectId: string) => {
|
|
128
|
-
this.objects.delete(calendarObjectId);
|
|
129
|
-
},
|
|
130
|
-
findByResourceName: async (calendarId: string, resourceName: string) =>
|
|
131
|
-
this.objects.get(deriveCalendarObjectId(calendarId, resourceName)) ??
|
|
132
|
-
null,
|
|
133
|
-
findByUid: async (calendarId: string, icalUid: string) =>
|
|
134
|
-
[...this.objects.values()].find(
|
|
135
|
-
(object) =>
|
|
136
|
-
object.calendarId === calendarId && object.icalUid === icalUid,
|
|
137
|
-
) ?? null,
|
|
138
|
-
listByCalendar: async (calendarId: string) =>
|
|
139
|
-
[...this.objects.values()].filter(
|
|
140
|
-
(object) => object.calendarId === calendarId,
|
|
141
|
-
),
|
|
142
|
-
listChangedSince: async (calendarId: string, syncSequence: number) =>
|
|
143
|
-
[...this.objects.values()]
|
|
144
|
-
.filter(
|
|
145
|
-
(object) =>
|
|
146
|
-
object.calendarId === calendarId &&
|
|
147
|
-
object.syncSequence > syncSequence,
|
|
148
|
-
)
|
|
149
|
-
.sort((left, right) => left.syncSequence - right.syncSequence),
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
private readonly eventIndexRepo: ICalendarEventIndexRepository = {
|
|
153
|
-
replaceForObject: async (
|
|
154
|
-
calendarId: string,
|
|
155
|
-
calendarObjectId: string,
|
|
156
|
-
occurrences: CalendarOccurrenceInput[],
|
|
157
|
-
) => {
|
|
158
|
-
const now = Date.now();
|
|
159
|
-
this.occurrences.set(
|
|
160
|
-
calendarObjectId,
|
|
161
|
-
occurrences.map((occurrence) => ({
|
|
162
|
-
...occurrence,
|
|
163
|
-
calendarId,
|
|
164
|
-
calendarObjectId,
|
|
165
|
-
createdAt: now,
|
|
166
|
-
updatedAt: now,
|
|
167
|
-
})),
|
|
168
|
-
);
|
|
169
|
-
},
|
|
170
|
-
deleteForObject: async (_calendarId: string, calendarObjectId: string) => {
|
|
171
|
-
this.occurrences.delete(calendarObjectId);
|
|
172
|
-
},
|
|
173
|
-
listForObject: async (_calendarId: string, calendarObjectId: string) =>
|
|
174
|
-
this.occurrences.get(calendarObjectId) ?? [],
|
|
175
|
-
listByStartRange: async (
|
|
176
|
-
calendarId: string,
|
|
177
|
-
startAt: string,
|
|
178
|
-
endAt: string,
|
|
179
|
-
) =>
|
|
180
|
-
[...this.occurrences.values()]
|
|
181
|
-
.flat()
|
|
182
|
-
.filter(
|
|
183
|
-
(row) =>
|
|
184
|
-
row.calendarId === calendarId &&
|
|
185
|
-
row.startAt >= startAt &&
|
|
186
|
-
row.startAt < endAt,
|
|
187
|
-
)
|
|
188
|
-
.sort((left, right) => left.startAt.localeCompare(right.startAt)),
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
transaction<T>(
|
|
192
|
-
fn: (repos: {
|
|
193
|
-
calendarCollection: ICalendarCollectionRepository;
|
|
194
|
-
calendarObject: ICalendarObjectRepository;
|
|
195
|
-
calendarEventIndex: ICalendarEventIndexRepository;
|
|
196
|
-
}) => Promise<T>,
|
|
197
|
-
): Promise<T> {
|
|
198
|
-
return fn({
|
|
199
|
-
calendarCollection: this.collectionRepo,
|
|
200
|
-
calendarObject: this.objectRepo,
|
|
201
|
-
calendarEventIndex: this.eventIndexRepo,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
14
|
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
207
15
|
|
|
208
16
|
const provisioned = async (): Promise<{
|