@remit/data-ports 0.0.51 → 0.0.52
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 +1 -1
- package/src/conformance/calendar-collection.ts +207 -0
- package/src/conformance/calendar-event-index.ts +157 -0
- package/src/conformance/calendar-object.ts +187 -0
- package/src/conformance/index.ts +3 -0
- package/src/id.test.ts +82 -0
- package/src/id.ts +43 -0
- package/src/index.ts +14 -0
- package/src/interfaces/calendar-collection.ts +42 -0
- package/src/interfaces/calendar-event-index.ts +34 -0
- package/src/interfaces/calendar-object.ts +33 -0
- package/src/interfaces/calendar-unit-of-work.ts +31 -0
- package/src/types.ts +55 -0
package/package.json
CHANGED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { CalendarColor, CalendarSource } from "@remit/domain-enums";
|
|
4
|
+
import { deriveCalendarId } from "../id.js";
|
|
5
|
+
|
|
6
|
+
import type { ICalendarCollectionRepository } from "../interfaces/calendar-collection.js";
|
|
7
|
+
import type { RepositoryConformanceHarness } from "./harness.js";
|
|
8
|
+
|
|
9
|
+
export function calendarCollectionRepositoryConformance(
|
|
10
|
+
harness: RepositoryConformanceHarness<ICalendarCollectionRepository>,
|
|
11
|
+
): void {
|
|
12
|
+
describe("ICalendarCollectionRepository conformance", () => {
|
|
13
|
+
let repo: ICalendarCollectionRepository;
|
|
14
|
+
|
|
15
|
+
before(async () => {
|
|
16
|
+
repo = await harness.createRepository();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
after(() => harness.teardown());
|
|
20
|
+
|
|
21
|
+
test("create derives calendarId from the account config and the url segment", async () => {
|
|
22
|
+
const accountConfigId = harness.makeId();
|
|
23
|
+
|
|
24
|
+
const calendar = await repo.create({
|
|
25
|
+
accountConfigId,
|
|
26
|
+
urlSegment: "default",
|
|
27
|
+
displayName: "Calendar",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
assert.equal(
|
|
31
|
+
calendar.calendarId,
|
|
32
|
+
deriveCalendarId(accountConfigId, "default"),
|
|
33
|
+
);
|
|
34
|
+
assert.equal(calendar.color, CalendarColor.Cal1);
|
|
35
|
+
assert.equal(calendar.source, CalendarSource.UserCreated);
|
|
36
|
+
assert.equal(calendar.componentSet, "VeventOnly");
|
|
37
|
+
assert.equal(calendar.timezone, "");
|
|
38
|
+
assert.equal(calendar.syncSequence, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("create is idempotent, so first-use provisioning never forks a second collection", async () => {
|
|
42
|
+
const accountConfigId = harness.makeId();
|
|
43
|
+
|
|
44
|
+
const first = await repo.create({
|
|
45
|
+
accountConfigId,
|
|
46
|
+
urlSegment: "default",
|
|
47
|
+
displayName: "Calendar",
|
|
48
|
+
source: CalendarSource.Default,
|
|
49
|
+
});
|
|
50
|
+
const second = await repo.create({
|
|
51
|
+
accountConfigId,
|
|
52
|
+
urlSegment: "default",
|
|
53
|
+
displayName: "Calendar",
|
|
54
|
+
source: CalendarSource.Default,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.equal(second.calendarId, first.calendarId);
|
|
58
|
+
const all = await repo.listByAccountConfig(accountConfigId);
|
|
59
|
+
assert.equal(all.length, 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("get throws a not-found error for a missing collection", async () => {
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
repo.get(harness.makeId(), harness.makeId()),
|
|
65
|
+
(error) => harness.isNotFoundError(error),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("update changes the display overlay, never the key", async () => {
|
|
70
|
+
const accountConfigId = harness.makeId();
|
|
71
|
+
const calendar = await repo.create({
|
|
72
|
+
accountConfigId,
|
|
73
|
+
urlSegment: "work",
|
|
74
|
+
displayName: "Work",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const updated = await repo.update(accountConfigId, calendar.calendarId, {
|
|
78
|
+
displayName: "Work & Travel",
|
|
79
|
+
color: CalendarColor.Cal4,
|
|
80
|
+
timezone: "Europe/Amsterdam",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assert.equal(updated.calendarId, calendar.calendarId);
|
|
84
|
+
assert.equal(updated.urlSegment, "work");
|
|
85
|
+
assert.equal(updated.displayName, "Work & Travel");
|
|
86
|
+
assert.equal(updated.color, CalendarColor.Cal4);
|
|
87
|
+
assert.equal(updated.timezone, "Europe/Amsterdam");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("update throws a not-found error for a missing collection", async () => {
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
repo.update(harness.makeId(), harness.makeId(), {
|
|
93
|
+
displayName: "x",
|
|
94
|
+
}),
|
|
95
|
+
(error) => harness.isNotFoundError(error),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("findByUrlSegment resolves the path segment a client bookmarked", async () => {
|
|
100
|
+
const accountConfigId = harness.makeId();
|
|
101
|
+
const calendar = await repo.create({
|
|
102
|
+
accountConfigId,
|
|
103
|
+
urlSegment: "personal",
|
|
104
|
+
displayName: "Personal",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const found = await repo.findByUrlSegment(accountConfigId, "personal");
|
|
108
|
+
assert.equal(found?.calendarId, calendar.calendarId);
|
|
109
|
+
|
|
110
|
+
assert.equal(
|
|
111
|
+
await repo.findByUrlSegment(accountConfigId, "nonexistent"),
|
|
112
|
+
null,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a segment stored in one casing is found in another, and by its derived id", async () => {
|
|
117
|
+
// The id derivation folds case; the stored column has to fold with it,
|
|
118
|
+
// or one DAV path resolves through `findByUrlSegment` and not through a
|
|
119
|
+
// `get` by derived id.
|
|
120
|
+
const accountConfigId = harness.makeId();
|
|
121
|
+
|
|
122
|
+
const calendar = await repo.create({
|
|
123
|
+
accountConfigId,
|
|
124
|
+
urlSegment: "Work-Calendar",
|
|
125
|
+
displayName: "Work",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.equal(calendar.urlSegment, "work-calendar");
|
|
129
|
+
assert.equal(
|
|
130
|
+
calendar.calendarId,
|
|
131
|
+
deriveCalendarId(accountConfigId, "WORK-CALENDAR"),
|
|
132
|
+
);
|
|
133
|
+
assert.equal(
|
|
134
|
+
(await repo.findByUrlSegment(accountConfigId, "WORK-CALENDAR"))
|
|
135
|
+
?.calendarId,
|
|
136
|
+
calendar.calendarId,
|
|
137
|
+
);
|
|
138
|
+
assert.equal(
|
|
139
|
+
(await repo.get(accountConfigId, calendar.calendarId)).urlSegment,
|
|
140
|
+
"work-calendar",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("listByAccountConfig scopes to the account config", async () => {
|
|
145
|
+
const accountConfigId = harness.makeId();
|
|
146
|
+
const other = harness.makeId();
|
|
147
|
+
|
|
148
|
+
const mine = await repo.create({
|
|
149
|
+
accountConfigId,
|
|
150
|
+
urlSegment: "default",
|
|
151
|
+
displayName: "Mine",
|
|
152
|
+
});
|
|
153
|
+
await repo.create({
|
|
154
|
+
accountConfigId: other,
|
|
155
|
+
urlSegment: "default",
|
|
156
|
+
displayName: "Foreign",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const calendars = await repo.listByAccountConfig(accountConfigId);
|
|
160
|
+
assert.equal(calendars.length, 1);
|
|
161
|
+
assert.equal(calendars[0]?.calendarId, mine.calendarId);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("bumpSyncSequence hands every caller a distinct increasing value", async () => {
|
|
165
|
+
const accountConfigId = harness.makeId();
|
|
166
|
+
const calendar = await repo.create({
|
|
167
|
+
accountConfigId,
|
|
168
|
+
urlSegment: "seq",
|
|
169
|
+
displayName: "Seq",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const sequences: number[] = [];
|
|
173
|
+
for (let i = 0; i < 5; i += 1) {
|
|
174
|
+
sequences.push(
|
|
175
|
+
await repo.bumpSyncSequence(accountConfigId, calendar.calendarId),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
assert.deepEqual(sequences, [1, 2, 3, 4, 5]);
|
|
180
|
+
const reread = await repo.get(accountConfigId, calendar.calendarId);
|
|
181
|
+
assert.equal(reread.syncSequence, 5);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("bumpSyncSequence throws a not-found error for a missing collection", async () => {
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
repo.bumpSyncSequence(harness.makeId(), harness.makeId()),
|
|
187
|
+
(error) => harness.isNotFoundError(error),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("delete removes the row", async () => {
|
|
192
|
+
const accountConfigId = harness.makeId();
|
|
193
|
+
const calendar = await repo.create({
|
|
194
|
+
accountConfigId,
|
|
195
|
+
urlSegment: "gone",
|
|
196
|
+
displayName: "Gone",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await repo.delete(accountConfigId, calendar.calendarId);
|
|
200
|
+
|
|
201
|
+
await assert.rejects(
|
|
202
|
+
repo.get(accountConfigId, calendar.calendarId),
|
|
203
|
+
(error) => harness.isNotFoundError(error),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import type { ICalendarEventIndexRepository } from "../interfaces/calendar-event-index.js";
|
|
4
|
+
import type { CalendarOccurrenceInput } from "../types.js";
|
|
5
|
+
import type { RepositoryConformanceHarness } from "./harness.js";
|
|
6
|
+
|
|
7
|
+
const occurrence = (
|
|
8
|
+
recurrenceId: string,
|
|
9
|
+
startAt: string,
|
|
10
|
+
endAt: string,
|
|
11
|
+
): CalendarOccurrenceInput => ({
|
|
12
|
+
recurrenceId,
|
|
13
|
+
startAt,
|
|
14
|
+
endAt,
|
|
15
|
+
allDay: false,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function calendarEventIndexRepositoryConformance(
|
|
19
|
+
harness: RepositoryConformanceHarness<ICalendarEventIndexRepository>,
|
|
20
|
+
): void {
|
|
21
|
+
describe("ICalendarEventIndexRepository conformance", () => {
|
|
22
|
+
let repo: ICalendarEventIndexRepository;
|
|
23
|
+
|
|
24
|
+
before(async () => {
|
|
25
|
+
repo = await harness.createRepository();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
after(() => harness.teardown());
|
|
29
|
+
|
|
30
|
+
test("replaceForObject writes the master occurrence under an empty recurrenceId", async () => {
|
|
31
|
+
const calendarId = harness.makeId();
|
|
32
|
+
const calendarObjectId = harness.makeId();
|
|
33
|
+
|
|
34
|
+
await repo.replaceForObject(calendarId, calendarObjectId, [
|
|
35
|
+
occurrence("", "2026-08-26T07:00:00Z", "2026-08-26T08:00:00Z"),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const rows = await repo.listForObject(calendarId, calendarObjectId);
|
|
39
|
+
assert.equal(rows.length, 1);
|
|
40
|
+
assert.equal(rows[0]?.recurrenceId, "");
|
|
41
|
+
assert.equal(rows[0]?.startAt, "2026-08-26T07:00:00Z");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("replaceForObject drops the occurrences a shortened series no longer has", async () => {
|
|
45
|
+
const calendarId = harness.makeId();
|
|
46
|
+
const calendarObjectId = harness.makeId();
|
|
47
|
+
|
|
48
|
+
await repo.replaceForObject(calendarId, calendarObjectId, [
|
|
49
|
+
occurrence(
|
|
50
|
+
"2026-08-26T07:00:00Z",
|
|
51
|
+
"2026-08-26T07:00:00Z",
|
|
52
|
+
"2026-08-26T08:00:00Z",
|
|
53
|
+
),
|
|
54
|
+
occurrence(
|
|
55
|
+
"2026-09-02T07:00:00Z",
|
|
56
|
+
"2026-09-02T07:00:00Z",
|
|
57
|
+
"2026-09-02T08:00:00Z",
|
|
58
|
+
),
|
|
59
|
+
]);
|
|
60
|
+
await repo.replaceForObject(calendarId, calendarObjectId, [
|
|
61
|
+
occurrence(
|
|
62
|
+
"2026-08-26T07:00:00Z",
|
|
63
|
+
"2026-08-26T07:00:00Z",
|
|
64
|
+
"2026-08-26T08:00:00Z",
|
|
65
|
+
),
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const rows = await repo.listForObject(calendarId, calendarObjectId);
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
rows.map((row) => row.recurrenceId),
|
|
71
|
+
["2026-08-26T07:00:00Z"],
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("replaceForObject leaves the other resources of the collection alone", async () => {
|
|
76
|
+
const calendarId = harness.makeId();
|
|
77
|
+
const mine = harness.makeId();
|
|
78
|
+
const other = harness.makeId();
|
|
79
|
+
|
|
80
|
+
await repo.replaceForObject(calendarId, other, [
|
|
81
|
+
occurrence("", "2026-08-26T07:00:00Z", "2026-08-26T08:00:00Z"),
|
|
82
|
+
]);
|
|
83
|
+
await repo.replaceForObject(calendarId, mine, []);
|
|
84
|
+
|
|
85
|
+
assert.equal((await repo.listForObject(calendarId, other)).length, 1);
|
|
86
|
+
assert.equal((await repo.listForObject(calendarId, mine)).length, 0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("listByStartRange returns occurrences in start order, half-open on the end", async () => {
|
|
90
|
+
const calendarId = harness.makeId();
|
|
91
|
+
const calendarObjectId = harness.makeId();
|
|
92
|
+
|
|
93
|
+
await repo.replaceForObject(calendarId, calendarObjectId, [
|
|
94
|
+
occurrence(
|
|
95
|
+
"2026-09-02T07:00:00Z",
|
|
96
|
+
"2026-09-02T07:00:00Z",
|
|
97
|
+
"2026-09-02T08:00:00Z",
|
|
98
|
+
),
|
|
99
|
+
occurrence(
|
|
100
|
+
"2026-08-26T07:00:00Z",
|
|
101
|
+
"2026-08-26T07:00:00Z",
|
|
102
|
+
"2026-08-26T08:00:00Z",
|
|
103
|
+
),
|
|
104
|
+
occurrence(
|
|
105
|
+
"2026-09-09T07:00:00Z",
|
|
106
|
+
"2026-09-09T07:00:00Z",
|
|
107
|
+
"2026-09-09T08:00:00Z",
|
|
108
|
+
),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const rows = await repo.listByStartRange(
|
|
112
|
+
calendarId,
|
|
113
|
+
"2026-08-26T07:00:00Z",
|
|
114
|
+
"2026-09-09T07:00:00Z",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
rows.map((row) => row.startAt),
|
|
119
|
+
["2026-08-26T07:00:00Z", "2026-09-02T07:00:00Z"],
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("listByStartRange scopes to the collection", async () => {
|
|
124
|
+
const calendarId = harness.makeId();
|
|
125
|
+
const other = harness.makeId();
|
|
126
|
+
|
|
127
|
+
await repo.replaceForObject(calendarId, harness.makeId(), [
|
|
128
|
+
occurrence("", "2026-08-26T07:00:00Z", "2026-08-26T08:00:00Z"),
|
|
129
|
+
]);
|
|
130
|
+
await repo.replaceForObject(other, harness.makeId(), [
|
|
131
|
+
occurrence("", "2026-08-26T07:00:00Z", "2026-08-26T08:00:00Z"),
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const rows = await repo.listByStartRange(
|
|
135
|
+
calendarId,
|
|
136
|
+
"2026-08-01T00:00:00Z",
|
|
137
|
+
"2026-09-01T00:00:00Z",
|
|
138
|
+
);
|
|
139
|
+
assert.equal(rows.length, 1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("deleteForObject removes every occurrence of one resource", async () => {
|
|
143
|
+
const calendarId = harness.makeId();
|
|
144
|
+
const calendarObjectId = harness.makeId();
|
|
145
|
+
|
|
146
|
+
await repo.replaceForObject(calendarId, calendarObjectId, [
|
|
147
|
+
occurrence("", "2026-08-26T07:00:00Z", "2026-08-26T08:00:00Z"),
|
|
148
|
+
]);
|
|
149
|
+
await repo.deleteForObject(calendarId, calendarObjectId);
|
|
150
|
+
|
|
151
|
+
assert.equal(
|
|
152
|
+
(await repo.listForObject(calendarId, calendarObjectId)).length,
|
|
153
|
+
0,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
CalendarEventStatus,
|
|
5
|
+
CalendarTransparency,
|
|
6
|
+
ZoneCertainty,
|
|
7
|
+
} from "@remit/domain-enums";
|
|
8
|
+
import { deriveCalendarObjectId } from "../id.js";
|
|
9
|
+
import type { ICalendarObjectRepository } from "../interfaces/calendar-object.js";
|
|
10
|
+
import type { PutCalendarObjectInput } from "../types.js";
|
|
11
|
+
import type { RepositoryConformanceHarness } from "./harness.js";
|
|
12
|
+
|
|
13
|
+
const ICAL_DATA = [
|
|
14
|
+
"BEGIN:VCALENDAR",
|
|
15
|
+
"VERSION:2.0",
|
|
16
|
+
"BEGIN:VEVENT",
|
|
17
|
+
"UID:conformance@example.com",
|
|
18
|
+
"DTSTART:20260826T090000Z",
|
|
19
|
+
"DTEND:20260826T100000Z",
|
|
20
|
+
"SUMMARY:Stand-up",
|
|
21
|
+
"END:VEVENT",
|
|
22
|
+
"END:VCALENDAR",
|
|
23
|
+
"",
|
|
24
|
+
].join("\r\n");
|
|
25
|
+
|
|
26
|
+
const objectInput = (
|
|
27
|
+
calendarId: string,
|
|
28
|
+
overrides: Partial<PutCalendarObjectInput> = {},
|
|
29
|
+
): PutCalendarObjectInput => ({
|
|
30
|
+
calendarId,
|
|
31
|
+
resourceName: "conformance.ics",
|
|
32
|
+
icalUid: "conformance@example.com",
|
|
33
|
+
icalData: ICAL_DATA,
|
|
34
|
+
etag: "a".repeat(64),
|
|
35
|
+
sequence: 0,
|
|
36
|
+
syncSequence: 1,
|
|
37
|
+
summary: "Stand-up",
|
|
38
|
+
dtStart: "2026-08-26T09:00:00+00:00",
|
|
39
|
+
dtEnd: "2026-08-26T10:00:00+00:00",
|
|
40
|
+
allDay: false,
|
|
41
|
+
zoneCertainty: ZoneCertainty.Explicit,
|
|
42
|
+
status: CalendarEventStatus.Confirmed,
|
|
43
|
+
transparency: CalendarTransparency.Opaque,
|
|
44
|
+
hasRecurrence: false,
|
|
45
|
+
expandedThrough: "",
|
|
46
|
+
...overrides,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function calendarObjectRepositoryConformance(
|
|
50
|
+
harness: RepositoryConformanceHarness<ICalendarObjectRepository>,
|
|
51
|
+
): void {
|
|
52
|
+
describe("ICalendarObjectRepository conformance", () => {
|
|
53
|
+
let repo: ICalendarObjectRepository;
|
|
54
|
+
|
|
55
|
+
before(async () => {
|
|
56
|
+
repo = await harness.createRepository();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
after(() => harness.teardown());
|
|
60
|
+
|
|
61
|
+
test("put derives calendarObjectId from the collection and the resource name", async () => {
|
|
62
|
+
const calendarId = harness.makeId();
|
|
63
|
+
|
|
64
|
+
const object = await repo.put(objectInput(calendarId));
|
|
65
|
+
|
|
66
|
+
assert.equal(
|
|
67
|
+
object.calendarObjectId,
|
|
68
|
+
deriveCalendarObjectId(calendarId, "conformance.ics"),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("put stores the iCalendar bytes verbatim, CRLF intact", async () => {
|
|
73
|
+
const calendarId = harness.makeId();
|
|
74
|
+
|
|
75
|
+
await repo.put(objectInput(calendarId));
|
|
76
|
+
const stored = await repo.findByResourceName(
|
|
77
|
+
calendarId,
|
|
78
|
+
"conformance.ics",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
assert.equal(stored?.icalData, ICAL_DATA);
|
|
82
|
+
assert.ok(stored?.icalData.includes("\r\n"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("put rewrites the same resource rather than forking a duplicate", async () => {
|
|
86
|
+
const calendarId = harness.makeId();
|
|
87
|
+
|
|
88
|
+
await repo.put(objectInput(calendarId));
|
|
89
|
+
const second = await repo.put(
|
|
90
|
+
objectInput(calendarId, {
|
|
91
|
+
summary: "Stand-up (moved)",
|
|
92
|
+
etag: "b".repeat(64),
|
|
93
|
+
syncSequence: 2,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
assert.equal(second.summary, "Stand-up (moved)");
|
|
98
|
+
assert.equal(second.syncSequence, 2);
|
|
99
|
+
const all = await repo.listByCalendar(calendarId);
|
|
100
|
+
assert.equal(all.length, 1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("get throws a not-found error for a missing resource", async () => {
|
|
104
|
+
await assert.rejects(
|
|
105
|
+
repo.get(harness.makeId(), harness.makeId()),
|
|
106
|
+
(error) => harness.isNotFoundError(error),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("findByResourceName returns null for a name the collection does not hold", async () => {
|
|
111
|
+
assert.equal(
|
|
112
|
+
await repo.findByResourceName(harness.makeId(), "absent.ics"),
|
|
113
|
+
null,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("findByUid resolves the resource carrying an iCalendar UID", async () => {
|
|
118
|
+
const calendarId = harness.makeId();
|
|
119
|
+
const object = await repo.put(objectInput(calendarId));
|
|
120
|
+
|
|
121
|
+
const found = await repo.findByUid(calendarId, "conformance@example.com");
|
|
122
|
+
assert.equal(found?.calendarObjectId, object.calendarObjectId);
|
|
123
|
+
|
|
124
|
+
assert.equal(
|
|
125
|
+
await repo.findByUid(calendarId, "absent@example.com"),
|
|
126
|
+
null,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("listByCalendar scopes to the collection", async () => {
|
|
131
|
+
const calendarId = harness.makeId();
|
|
132
|
+
const other = harness.makeId();
|
|
133
|
+
|
|
134
|
+
const mine = await repo.put(objectInput(calendarId));
|
|
135
|
+
await repo.put(objectInput(other));
|
|
136
|
+
|
|
137
|
+
const objects = await repo.listByCalendar(calendarId);
|
|
138
|
+
assert.equal(objects.length, 1);
|
|
139
|
+
assert.equal(objects[0]?.calendarObjectId, mine.calendarObjectId);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("listChangedSince returns later writes in change order, excluding the token itself", async () => {
|
|
143
|
+
const calendarId = harness.makeId();
|
|
144
|
+
|
|
145
|
+
await repo.put(
|
|
146
|
+
objectInput(calendarId, {
|
|
147
|
+
resourceName: "one.ics",
|
|
148
|
+
icalUid: "one@example.com",
|
|
149
|
+
syncSequence: 1,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
await repo.put(
|
|
153
|
+
objectInput(calendarId, {
|
|
154
|
+
resourceName: "two.ics",
|
|
155
|
+
icalUid: "two@example.com",
|
|
156
|
+
syncSequence: 2,
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
await repo.put(
|
|
160
|
+
objectInput(calendarId, {
|
|
161
|
+
resourceName: "three.ics",
|
|
162
|
+
icalUid: "three@example.com",
|
|
163
|
+
syncSequence: 3,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const changed = await repo.listChangedSince(calendarId, 1);
|
|
168
|
+
|
|
169
|
+
assert.deepEqual(
|
|
170
|
+
changed.map((object) => object.resourceName),
|
|
171
|
+
["two.ics", "three.ics"],
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("delete removes the row", async () => {
|
|
176
|
+
const calendarId = harness.makeId();
|
|
177
|
+
const object = await repo.put(objectInput(calendarId));
|
|
178
|
+
|
|
179
|
+
await repo.delete(calendarId, object.calendarObjectId);
|
|
180
|
+
|
|
181
|
+
await assert.rejects(
|
|
182
|
+
repo.get(calendarId, object.calendarObjectId),
|
|
183
|
+
(error) => harness.isNotFoundError(error),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
package/src/conformance/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { calendarCollectionRepositoryConformance } from "./calendar-collection.js";
|
|
2
|
+
export { calendarEventIndexRepositoryConformance } from "./calendar-event-index.js";
|
|
3
|
+
export { calendarObjectRepositoryConformance } from "./calendar-object.js";
|
|
1
4
|
export type { RepositoryConformanceHarness } from "./harness.js";
|
|
2
5
|
export { labelRepositoryConformance } from "./label.js";
|
|
3
6
|
export { senderSignerStandingRepositoryConformance } from "./sender-signer-standing.js";
|
package/src/id.test.ts
CHANGED
|
@@ -4,12 +4,15 @@ import {
|
|
|
4
4
|
base36uuidv5,
|
|
5
5
|
deriveAddressId,
|
|
6
6
|
deriveBodyPartId,
|
|
7
|
+
deriveCalendarId,
|
|
8
|
+
deriveCalendarObjectId,
|
|
7
9
|
deriveCopyMessageId,
|
|
8
10
|
deriveEnvelopeId,
|
|
9
11
|
deriveMessageId,
|
|
10
12
|
deriveMessageIdFromSource,
|
|
11
13
|
deriveQuarantineId,
|
|
12
14
|
deriveThreadId,
|
|
15
|
+
normalizeCalendarUrlSegment,
|
|
13
16
|
normalizeMessageIdHeader,
|
|
14
17
|
quarantineMessageIdHash,
|
|
15
18
|
REMIT_NAMESPACE,
|
|
@@ -100,6 +103,64 @@ describe("quarantineMessageIdHash", () => {
|
|
|
100
103
|
});
|
|
101
104
|
});
|
|
102
105
|
|
|
106
|
+
describe("normalizeCalendarUrlSegment", () => {
|
|
107
|
+
it("folds a segment to the one form the id and the stored column share", () => {
|
|
108
|
+
assert.equal(
|
|
109
|
+
normalizeCalendarUrlSegment(" Work-Calendar "),
|
|
110
|
+
"work-calendar",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("deriveCalendarId", () => {
|
|
116
|
+
it("resolves a DAV path segment case-insensitively", () => {
|
|
117
|
+
assert.equal(
|
|
118
|
+
deriveCalendarId(ACCOUNT, "Work"),
|
|
119
|
+
deriveCalendarId(ACCOUNT, "work"),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("derives from the folded segment, so a stored segment resolves to its own id", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
deriveCalendarId(ACCOUNT, " Work "),
|
|
126
|
+
deriveCalendarId(ACCOUNT, normalizeCalendarUrlSegment(" Work ")),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("separates the same segment across two account configs", () => {
|
|
131
|
+
assert.notEqual(
|
|
132
|
+
deriveCalendarId(ACCOUNT, "default"),
|
|
133
|
+
deriveCalendarId("acct-2", "default"),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("deriveCalendarObjectId", () => {
|
|
139
|
+
it("is stable for the same resource", () => {
|
|
140
|
+
assert.equal(
|
|
141
|
+
deriveCalendarObjectId("cal-1", "event.ics"),
|
|
142
|
+
deriveCalendarObjectId("cal-1", "event.ics"),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("separates the same resource name across two collections", () => {
|
|
147
|
+
assert.notEqual(
|
|
148
|
+
deriveCalendarObjectId("cal-1", "event.ics"),
|
|
149
|
+
deriveCalendarObjectId("cal-2", "event.ics"),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("keeps a resource name case-sensitive, unlike a collection segment", () => {
|
|
154
|
+
// A resource name is the literal last path segment a client PUT to, and
|
|
155
|
+
// CalDAV lets two resources in one collection differ only by case. Folding
|
|
156
|
+
// it would merge them into one row and lose the second.
|
|
157
|
+
assert.notEqual(
|
|
158
|
+
deriveCalendarObjectId("cal-1", "Event.ics"),
|
|
159
|
+
deriveCalendarObjectId("cal-1", "event.ics"),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
103
164
|
describe("derived ids are stored primary keys: a changed value orphans every row already written and needs a migration, never a new expectation here", () => {
|
|
104
165
|
const ACCOUNT = "account-golden";
|
|
105
166
|
const MESSAGE = "message-golden";
|
|
@@ -186,6 +247,27 @@ describe("derived ids are stored primary keys: a changed value orphans every row
|
|
|
186
247
|
);
|
|
187
248
|
});
|
|
188
249
|
|
|
250
|
+
it("deriveCalendarId pins the calendar primary key, lowercased segment included", () => {
|
|
251
|
+
assert.equal(
|
|
252
|
+
deriveCalendarId(ACCOUNT, "Work-Calendar"),
|
|
253
|
+
"ckygmpewwbyhzkrjs6ui29iuw",
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("deriveCalendarId pins the key of the default collection every account is provisioned", () => {
|
|
258
|
+
assert.equal(
|
|
259
|
+
deriveCalendarId(ACCOUNT, "default"),
|
|
260
|
+
"bg614rmeq926iyti6npylfzja",
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("deriveCalendarObjectId pins the calendar_object primary key, resource name case intact", () => {
|
|
265
|
+
assert.equal(
|
|
266
|
+
deriveCalendarObjectId("calendar-golden", "golden-event.ics"),
|
|
267
|
+
"aklxxezhc8icpuewggz983okb",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
189
271
|
it("quarantineMessageIdHash pins the stored Message-ID hash that correlates reports", () => {
|
|
190
272
|
assert.equal(
|
|
191
273
|
quarantineMessageIdHash("<golden@example.com>"),
|
package/src/id.ts
CHANGED
|
@@ -21,6 +21,49 @@ export const deriveAddressId = (
|
|
|
21
21
|
REMIT_NAMESPACE,
|
|
22
22
|
);
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The stored form of a calendar's URL segment (issue #15). A DAV path segment
|
|
26
|
+
* is matched case-insensitively by the clients that bookmark one, so the folded
|
|
27
|
+
* form is what both the derived id and the stored column are built from — the
|
|
28
|
+
* id derivation folding on its own would let `findByUrlSegment` and a `get` by
|
|
29
|
+
* derived id disagree about the same path.
|
|
30
|
+
*/
|
|
31
|
+
export const normalizeCalendarUrlSegment = (urlSegment: string): string =>
|
|
32
|
+
urlSegment.trim().toLowerCase();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Identity of a calendar collection (issue #15). Derived from the account
|
|
36
|
+
* config and the collection's URL segment, so the CalDAV path a client
|
|
37
|
+
* bookmarks resolves to a row with no lookup table, and provisioning the
|
|
38
|
+
* default collection twice — two concurrent first uses of the same account —
|
|
39
|
+
* writes the same row rather than a second, unreachable one.
|
|
40
|
+
*/
|
|
41
|
+
export const deriveCalendarId = (
|
|
42
|
+
accountConfigId: string,
|
|
43
|
+
urlSegment: string,
|
|
44
|
+
): string =>
|
|
45
|
+
base36uuidv5(
|
|
46
|
+
`calendar:${accountConfigId}:${normalizeCalendarUrlSegment(urlSegment)}`,
|
|
47
|
+
REMIT_NAMESPACE,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Identity of a calendar resource (issue #15). Derived from its collection and
|
|
52
|
+
* its resource name — the last path segment of the resource's URL — so a
|
|
53
|
+
* repeated PUT of the same resource rewrites its own row instead of forking a
|
|
54
|
+
* duplicate the collection can never reach.
|
|
55
|
+
*
|
|
56
|
+
* The resource name, not the iCalendar UID, is the name: CalDAV addresses a
|
|
57
|
+
* resource by URL, and a client is free to store the same UID under a second
|
|
58
|
+
* name (RFC 4791 4.1). Keying on the UID would make those two resources one
|
|
59
|
+
* row and silently lose the second.
|
|
60
|
+
*/
|
|
61
|
+
export const deriveCalendarObjectId = (
|
|
62
|
+
calendarId: string,
|
|
63
|
+
resourceName: string,
|
|
64
|
+
): string =>
|
|
65
|
+
base36uuidv5(`calendarobject:${calendarId}:${resourceName}`, REMIT_NAMESPACE);
|
|
66
|
+
|
|
24
67
|
export const isValidMessageId = (messageId: string | undefined): boolean => {
|
|
25
68
|
if (!messageId) return false;
|
|
26
69
|
const trimmed = messageId.trim();
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,13 @@ export type { IAccountConfigRepository } from "./interfaces/account-config.js";
|
|
|
4
4
|
export type { IAccountExportRequestRepository } from "./interfaces/account-export-request.js";
|
|
5
5
|
export type { IAccountSettingRepository } from "./interfaces/account-setting.js";
|
|
6
6
|
export type { IAddressRepository } from "./interfaces/address.js";
|
|
7
|
+
export type { ICalendarCollectionRepository } from "./interfaces/calendar-collection.js";
|
|
8
|
+
export type { ICalendarEventIndexRepository } from "./interfaces/calendar-event-index.js";
|
|
9
|
+
export type { ICalendarObjectRepository } from "./interfaces/calendar-object.js";
|
|
10
|
+
export type {
|
|
11
|
+
CalendarUnitOfWorkRepositories,
|
|
12
|
+
ICalendarUnitOfWork,
|
|
13
|
+
} from "./interfaces/calendar-unit-of-work.js";
|
|
7
14
|
export type { IConfigImportRepository } from "./interfaces/config-import.js";
|
|
8
15
|
export type { IEnvelopeRepository } from "./interfaces/envelope.js";
|
|
9
16
|
export type { IFilterRepository } from "./interfaces/filter.js";
|
|
@@ -57,12 +64,17 @@ export type {
|
|
|
57
64
|
BodyPartParameterUpsertInput,
|
|
58
65
|
BodyPartStorageItem,
|
|
59
66
|
BodyPartUpsertInput,
|
|
67
|
+
CalendarCollectionItem,
|
|
68
|
+
CalendarEventIndexItem,
|
|
69
|
+
CalendarObjectItem,
|
|
70
|
+
CalendarOccurrenceInput,
|
|
60
71
|
ConfigImportItem,
|
|
61
72
|
ConfigImportUnresolvedRefItem,
|
|
62
73
|
CreateAccountConfigInput,
|
|
63
74
|
CreateAccountExportRequestInput,
|
|
64
75
|
CreateAccountInput,
|
|
65
76
|
CreateAddressInput,
|
|
77
|
+
CreateCalendarCollectionInput,
|
|
66
78
|
CreateConfigImportInput,
|
|
67
79
|
CreateEnvelopeAddressInput,
|
|
68
80
|
CreateEnvelopeInput,
|
|
@@ -100,6 +112,7 @@ export type {
|
|
|
100
112
|
OrganizeJobRequestItem,
|
|
101
113
|
OutboxAttachmentItem,
|
|
102
114
|
OutboxMessageItem,
|
|
115
|
+
PutCalendarObjectInput,
|
|
103
116
|
PutMessageFlagPushInput,
|
|
104
117
|
PutMessagePlacementMoveInput,
|
|
105
118
|
QuarantineItem,
|
|
@@ -114,6 +127,7 @@ export type {
|
|
|
114
127
|
UpdateAccountExportRequestInput,
|
|
115
128
|
UpdateAccountInput,
|
|
116
129
|
UpdateAddressInput,
|
|
130
|
+
UpdateCalendarCollectionInput,
|
|
117
131
|
UpdateConfigImportInput,
|
|
118
132
|
UpdateEnvelopeInput,
|
|
119
133
|
UpdateFilterInput,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarCollectionItem,
|
|
3
|
+
CreateCalendarCollectionInput,
|
|
4
|
+
UpdateCalendarCollectionInput,
|
|
5
|
+
} from "../types.js";
|
|
6
|
+
|
|
7
|
+
export interface ICalendarCollectionRepository {
|
|
8
|
+
/**
|
|
9
|
+
* Writes the collection under its derived `calendarId`. Provisioning the
|
|
10
|
+
* same `(accountConfigId, urlSegment)` twice returns the existing row rather
|
|
11
|
+
* than a second one, so first-use provisioning is safe to run on every
|
|
12
|
+
* request without a prior read.
|
|
13
|
+
*/
|
|
14
|
+
create(input: CreateCalendarCollectionInput): Promise<CalendarCollectionItem>;
|
|
15
|
+
get(
|
|
16
|
+
accountConfigId: string,
|
|
17
|
+
calendarId: string,
|
|
18
|
+
): Promise<CalendarCollectionItem>;
|
|
19
|
+
update(
|
|
20
|
+
accountConfigId: string,
|
|
21
|
+
calendarId: string,
|
|
22
|
+
input: UpdateCalendarCollectionInput,
|
|
23
|
+
): Promise<CalendarCollectionItem>;
|
|
24
|
+
delete(accountConfigId: string, calendarId: string): Promise<void>;
|
|
25
|
+
listByAccountConfig(
|
|
26
|
+
accountConfigId: string,
|
|
27
|
+
): Promise<CalendarCollectionItem[]>;
|
|
28
|
+
findByUrlSegment(
|
|
29
|
+
accountConfigId: string,
|
|
30
|
+
urlSegment: string,
|
|
31
|
+
): Promise<CalendarCollectionItem | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Advances the collection's change counter by one and returns the new value,
|
|
34
|
+
* which the caller stamps on the object it just wrote. Atomic with respect
|
|
35
|
+
* to concurrent writers: a read-then-write here would hand two writers the
|
|
36
|
+
* same sequence and lose one of them from every later sync report.
|
|
37
|
+
*/
|
|
38
|
+
bumpSyncSequence(
|
|
39
|
+
accountConfigId: string,
|
|
40
|
+
calendarId: string,
|
|
41
|
+
): Promise<number>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarEventIndexItem,
|
|
3
|
+
CalendarOccurrenceInput,
|
|
4
|
+
} from "../types.js";
|
|
5
|
+
|
|
6
|
+
export interface ICalendarEventIndexRepository {
|
|
7
|
+
/**
|
|
8
|
+
* Replaces every occurrence row of one resource with `occurrences`. Whole
|
|
9
|
+
* replacement rather than a merge: an edit that shortens a series must
|
|
10
|
+
* remove the occurrences it dropped, and a diff would leave them behind as
|
|
11
|
+
* events the calendar view still shows but the resource no longer has.
|
|
12
|
+
*/
|
|
13
|
+
replaceForObject(
|
|
14
|
+
calendarId: string,
|
|
15
|
+
calendarObjectId: string,
|
|
16
|
+
occurrences: CalendarOccurrenceInput[],
|
|
17
|
+
): Promise<void>;
|
|
18
|
+
deleteForObject(calendarId: string, calendarObjectId: string): Promise<void>;
|
|
19
|
+
listForObject(
|
|
20
|
+
calendarId: string,
|
|
21
|
+
calendarObjectId: string,
|
|
22
|
+
): Promise<CalendarEventIndexItem[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Occurrences starting within `[startAt, endAt)`, in start order — the
|
|
25
|
+
* calendar-view read. Bounded by start rather than by overlap: an
|
|
26
|
+
* occurrence that began before the window and runs into it is not returned,
|
|
27
|
+
* so a caller that must show those widens the window by its longest event.
|
|
28
|
+
*/
|
|
29
|
+
listByStartRange(
|
|
30
|
+
calendarId: string,
|
|
31
|
+
startAt: string,
|
|
32
|
+
endAt: string,
|
|
33
|
+
): Promise<CalendarEventIndexItem[]>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CalendarObjectItem, PutCalendarObjectInput } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export interface ICalendarObjectRepository {
|
|
4
|
+
put(input: PutCalendarObjectInput): Promise<CalendarObjectItem>;
|
|
5
|
+
get(
|
|
6
|
+
calendarId: string,
|
|
7
|
+
calendarObjectId: string,
|
|
8
|
+
): Promise<CalendarObjectItem>;
|
|
9
|
+
delete(calendarId: string, calendarObjectId: string): Promise<void>;
|
|
10
|
+
findByResourceName(
|
|
11
|
+
calendarId: string,
|
|
12
|
+
resourceName: string,
|
|
13
|
+
): Promise<CalendarObjectItem | null>;
|
|
14
|
+
/**
|
|
15
|
+
* The resource carrying an iCalendar UID, which is how a scheduling message
|
|
16
|
+
* or a calendar-query REPORT addresses an event — a client that has the UID
|
|
17
|
+
* generally does not have the resource name.
|
|
18
|
+
*/
|
|
19
|
+
findByUid(
|
|
20
|
+
calendarId: string,
|
|
21
|
+
icalUid: string,
|
|
22
|
+
): Promise<CalendarObjectItem | null>;
|
|
23
|
+
listByCalendar(calendarId: string): Promise<CalendarObjectItem[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Everything written to the collection after `syncSequence`, in change
|
|
26
|
+
* order — the read behind a WebDAV-Sync report. Exclusive of the token
|
|
27
|
+
* itself, so replaying a token returns only what the client has not seen.
|
|
28
|
+
*/
|
|
29
|
+
listChangedSince(
|
|
30
|
+
calendarId: string,
|
|
31
|
+
syncSequence: number,
|
|
32
|
+
): Promise<CalendarObjectItem[]>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ICalendarCollectionRepository } from "./calendar-collection.js";
|
|
2
|
+
import type { ICalendarEventIndexRepository } from "./calendar-event-index.js";
|
|
3
|
+
import type { ICalendarObjectRepository } from "./calendar-object.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The repositories one calendar write set touches, bound to a single unit of
|
|
7
|
+
* work.
|
|
8
|
+
*/
|
|
9
|
+
export interface CalendarUnitOfWorkRepositories {
|
|
10
|
+
calendarCollection: ICalendarCollectionRepository;
|
|
11
|
+
calendarObject: ICalendarObjectRepository;
|
|
12
|
+
calendarEventIndex: ICalendarEventIndexRepository;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runs a calendar write set as one atomic unit (issue #15). Writing a resource
|
|
17
|
+
* is three writes — the object, its replaced occurrence rows, and the
|
|
18
|
+
* collection's sequence bump — and none of them is meaningful without the
|
|
19
|
+
* others: an object with stale occurrences is a calendar showing events the
|
|
20
|
+
* resource no longer has, and an object written without the bump is a change no
|
|
21
|
+
* sync report will ever hand a client.
|
|
22
|
+
*
|
|
23
|
+
* A throw rolls the whole set back. Backends without cross-entity transactions
|
|
24
|
+
* supply a pass-through implementation that runs the callback against the plain
|
|
25
|
+
* repositories.
|
|
26
|
+
*/
|
|
27
|
+
export interface ICalendarUnitOfWork {
|
|
28
|
+
transaction<T>(
|
|
29
|
+
fn: (repos: CalendarUnitOfWorkRepositories) => Promise<T>,
|
|
30
|
+
): Promise<T>;
|
|
31
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,9 @@ import type {
|
|
|
6
6
|
BodyPartParameterSchema,
|
|
7
7
|
BodyPartSchema,
|
|
8
8
|
BodyPartStorageSchema,
|
|
9
|
+
CalendarCollectionSchema,
|
|
10
|
+
CalendarEventIndexSchema,
|
|
11
|
+
CalendarObjectSchema,
|
|
9
12
|
ConfigImportSchema,
|
|
10
13
|
ConfigImportUnresolvedRefSchema,
|
|
11
14
|
EnvelopeAddressSchema,
|
|
@@ -552,6 +555,58 @@ export type CreateFilterAnchorInput = Omit<
|
|
|
552
555
|
"createdAt" | "updatedAt"
|
|
553
556
|
>;
|
|
554
557
|
|
|
558
|
+
export type CalendarCollectionItem = z.infer<typeof CalendarCollectionSchema>;
|
|
559
|
+
export type CalendarObjectItem = z.infer<typeof CalendarObjectSchema>;
|
|
560
|
+
export type CalendarEventIndexItem = z.infer<typeof CalendarEventIndexSchema>;
|
|
561
|
+
|
|
562
|
+
// `calendarId` is derived from accountConfigId + urlSegment, and syncSequence
|
|
563
|
+
// starts at zero and only ever moves through the collection's own bump — so
|
|
564
|
+
// neither is ever supplied here.
|
|
565
|
+
export type CreateCalendarCollectionInput = Omit<
|
|
566
|
+
CalendarCollectionItem,
|
|
567
|
+
| "calendarId"
|
|
568
|
+
| "syncSequence"
|
|
569
|
+
| "createdAt"
|
|
570
|
+
| "updatedAt"
|
|
571
|
+
| "color"
|
|
572
|
+
| "componentSet"
|
|
573
|
+
| "source"
|
|
574
|
+
| "timezone"
|
|
575
|
+
> & {
|
|
576
|
+
color?: CalendarCollectionItem["color"];
|
|
577
|
+
componentSet?: CalendarCollectionItem["componentSet"];
|
|
578
|
+
source?: CalendarCollectionItem["source"];
|
|
579
|
+
timezone?: CalendarCollectionItem["timezone"];
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
export type UpdateCalendarCollectionInput = Partial<
|
|
583
|
+
Pick<CalendarCollectionItem, "displayName" | "color" | "timezone">
|
|
584
|
+
>;
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* A whole calendar resource. Every projected column is supplied together with
|
|
588
|
+
* the `icalData` it was projected from — the repository never derives one from
|
|
589
|
+
* the other, so the projection can only ever be produced by the one place that
|
|
590
|
+
* owns it (`@remit/calendar-service`) and can never drift from the bytes.
|
|
591
|
+
*
|
|
592
|
+
* `calendarObjectId` is derived from calendarId + resourceName, so this is an
|
|
593
|
+
* upsert: writing the same resource twice rewrites its own row.
|
|
594
|
+
*/
|
|
595
|
+
export type PutCalendarObjectInput = Omit<
|
|
596
|
+
CalendarObjectItem,
|
|
597
|
+
"calendarObjectId" | "createdAt" | "updatedAt"
|
|
598
|
+
>;
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* One expanded occurrence. Written only as part of a full replacement of a
|
|
602
|
+
* resource's occurrences — there is no single-occurrence write, because a row
|
|
603
|
+
* here is never authoritative and never edited in place.
|
|
604
|
+
*/
|
|
605
|
+
export type CalendarOccurrenceInput = Omit<
|
|
606
|
+
CalendarEventIndexItem,
|
|
607
|
+
"calendarId" | "calendarObjectId" | "createdAt" | "updatedAt"
|
|
608
|
+
>;
|
|
609
|
+
|
|
555
610
|
export type SenderSignerStandingItem = z.infer<
|
|
556
611
|
typeof SenderSignerStandingSchema
|
|
557
612
|
>;
|