@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/data-ports",
3
- "version": "0.0.51",
3
+ "version": "0.0.52",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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
+ }
@@ -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
  >;