@remit/drizzle-service 0.0.66 → 0.0.67

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/drizzle-service",
3
- "version": "0.0.66",
3
+ "version": "0.0.67",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -18,6 +18,7 @@
18
18
  "fix": "biome check --write src"
19
19
  },
20
20
  "devDependencies": {
21
+ "@remit/calendar-service": "*",
21
22
  "@remit/config-transfer": "*",
22
23
  "drizzle-kit": "^0.31.1",
23
24
  "tsx": "*"
package/src/index.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { CreateFailedConflictError, NotFoundError } from "./error.js";
2
+ export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
3
+ export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
4
+ export { CalendarObjectRepo } from "./repos/calendar-object.js";
5
+ export { DrizzleCalendarUnitOfWork } from "./repos/calendar-unit-of-work.js";
2
6
  export {
3
7
  type CascadeDeleteLogger,
4
8
  type CascadeDeleter,
@@ -0,0 +1,23 @@
1
+ import { calendarCollectionRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { calendarTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { CalendarCollectionRepo } from "./calendar-collection.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ calendarCollectionRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ calendars: calendarTable,
14
+ });
15
+ close = closeDb;
16
+ return new CalendarCollectionRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -0,0 +1,180 @@
1
+ import type {
2
+ CalendarCollectionItem,
3
+ CreateCalendarCollectionInput,
4
+ ICalendarCollectionRepository,
5
+ UpdateCalendarCollectionInput,
6
+ } from "@remit/data-ports";
7
+ import {
8
+ deriveCalendarId,
9
+ normalizeCalendarUrlSegment,
10
+ } from "@remit/data-ports/id";
11
+ import { and, asc, eq, sql } from "drizzle-orm";
12
+ import type { Db } from "../db.js";
13
+ import { NotFoundError } from "../error.js";
14
+ import { calendarTable } from "../schema.js";
15
+
16
+ type DB = Db<Record<string, unknown>>;
17
+
18
+ function rowToCalendar(
19
+ row: typeof calendarTable.$inferSelect,
20
+ ): CalendarCollectionItem {
21
+ return {
22
+ calendarId: row.calendarId,
23
+ accountConfigId: row.accountConfigId,
24
+ urlSegment: row.urlSegment,
25
+ displayName: row.displayName,
26
+ color: row.color,
27
+ componentSet: row.componentSet,
28
+ source: row.source,
29
+ timezone: row.timezone,
30
+ syncSequence: row.syncSequence,
31
+ createdAt: row.createdAt,
32
+ updatedAt: row.updatedAt,
33
+ };
34
+ }
35
+
36
+ export class CalendarCollectionRepo implements ICalendarCollectionRepository {
37
+ constructor(private db: DB) {}
38
+
39
+ /**
40
+ * Provisioning is idempotent by construction: `calendarId` is derived, so a
41
+ * second create of the same `(accountConfigId, urlSegment)` collides with
42
+ * the row it already wrote. The conflict keeps the stored row rather than
43
+ * overwriting it — two concurrent first uses of an account must not have the
44
+ * loser reset a collection the winner has already had writes against.
45
+ */
46
+ async create(
47
+ input: CreateCalendarCollectionInput,
48
+ ): Promise<CalendarCollectionItem> {
49
+ const now = Date.now();
50
+ const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
51
+ const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
52
+ const [row] = await this.db
53
+ .insert(calendarTable)
54
+ .values({
55
+ calendarId,
56
+ accountConfigId: input.accountConfigId,
57
+ urlSegment,
58
+ displayName: input.displayName,
59
+ color: input.color ?? "Cal1",
60
+ componentSet: input.componentSet ?? "VeventOnly",
61
+ source: input.source ?? "UserCreated",
62
+ timezone: input.timezone ?? "",
63
+ syncSequence: 0,
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ })
67
+ .onConflictDoUpdate({
68
+ target: calendarTable.calendarId,
69
+ set: { updatedAt: now },
70
+ })
71
+ .returning();
72
+ return rowToCalendar(row);
73
+ }
74
+
75
+ async get(
76
+ accountConfigId: string,
77
+ calendarId: string,
78
+ ): Promise<CalendarCollectionItem> {
79
+ const [row] = await this.db
80
+ .select()
81
+ .from(calendarTable)
82
+ .where(
83
+ and(
84
+ eq(calendarTable.accountConfigId, accountConfigId),
85
+ eq(calendarTable.calendarId, calendarId),
86
+ ),
87
+ );
88
+ if (!row) {
89
+ throw new NotFoundError(`Calendar not found: ${calendarId}`);
90
+ }
91
+ return rowToCalendar(row);
92
+ }
93
+
94
+ async update(
95
+ accountConfigId: string,
96
+ calendarId: string,
97
+ input: UpdateCalendarCollectionInput,
98
+ ): Promise<CalendarCollectionItem> {
99
+ const [row] = await this.db
100
+ .update(calendarTable)
101
+ .set({ ...input, updatedAt: Date.now() })
102
+ .where(
103
+ and(
104
+ eq(calendarTable.accountConfigId, accountConfigId),
105
+ eq(calendarTable.calendarId, calendarId),
106
+ ),
107
+ )
108
+ .returning();
109
+ if (!row) {
110
+ throw new NotFoundError(`Calendar not found: ${calendarId}`);
111
+ }
112
+ return rowToCalendar(row);
113
+ }
114
+
115
+ async delete(accountConfigId: string, calendarId: string): Promise<void> {
116
+ await this.db
117
+ .delete(calendarTable)
118
+ .where(
119
+ and(
120
+ eq(calendarTable.accountConfigId, accountConfigId),
121
+ eq(calendarTable.calendarId, calendarId),
122
+ ),
123
+ );
124
+ }
125
+
126
+ async listByAccountConfig(
127
+ accountConfigId: string,
128
+ ): Promise<CalendarCollectionItem[]> {
129
+ const rows = await this.db
130
+ .select()
131
+ .from(calendarTable)
132
+ .where(eq(calendarTable.accountConfigId, accountConfigId))
133
+ .orderBy(asc(calendarTable.urlSegment));
134
+ return rows.map(rowToCalendar);
135
+ }
136
+
137
+ async findByUrlSegment(
138
+ accountConfigId: string,
139
+ urlSegment: string,
140
+ ): Promise<CalendarCollectionItem | null> {
141
+ const [row] = await this.db
142
+ .select()
143
+ .from(calendarTable)
144
+ .where(
145
+ and(
146
+ eq(calendarTable.accountConfigId, accountConfigId),
147
+ eq(calendarTable.urlSegment, normalizeCalendarUrlSegment(urlSegment)),
148
+ ),
149
+ );
150
+ return row ? rowToCalendar(row) : null;
151
+ }
152
+
153
+ /**
154
+ * One statement, so two writers can never read the same value and stamp it
155
+ * on two different objects — which would hide one of them from every later
156
+ * sync report that pages by the sequence.
157
+ */
158
+ async bumpSyncSequence(
159
+ accountConfigId: string,
160
+ calendarId: string,
161
+ ): Promise<number> {
162
+ const [row] = await this.db
163
+ .update(calendarTable)
164
+ .set({
165
+ syncSequence: sql`${calendarTable.syncSequence} + 1`,
166
+ updatedAt: Date.now(),
167
+ })
168
+ .where(
169
+ and(
170
+ eq(calendarTable.accountConfigId, accountConfigId),
171
+ eq(calendarTable.calendarId, calendarId),
172
+ ),
173
+ )
174
+ .returning();
175
+ if (!row) {
176
+ throw new NotFoundError(`Calendar not found: ${calendarId}`);
177
+ }
178
+ return row.syncSequence;
179
+ }
180
+ }
@@ -0,0 +1,23 @@
1
+ import { calendarEventIndexRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { calendarEventIndexTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { CalendarEventIndexRepo } from "./calendar-event-index.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ calendarEventIndexRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ calendarEventIndexes: calendarEventIndexTable,
14
+ });
15
+ close = closeDb;
16
+ return new CalendarEventIndexRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -0,0 +1,102 @@
1
+ import type {
2
+ CalendarEventIndexItem,
3
+ CalendarOccurrenceInput,
4
+ ICalendarEventIndexRepository,
5
+ } from "@remit/data-ports";
6
+ import { and, asc, eq, gte, lt } from "drizzle-orm";
7
+ import type { Db } from "../db.js";
8
+ import { calendarEventIndexTable } from "../schema.js";
9
+
10
+ type DB = Db<Record<string, unknown>>;
11
+
12
+ function rowToOccurrence(
13
+ row: typeof calendarEventIndexTable.$inferSelect,
14
+ ): CalendarEventIndexItem {
15
+ return {
16
+ calendarId: row.calendarId,
17
+ calendarObjectId: row.calendarObjectId,
18
+ recurrenceId: row.recurrenceId,
19
+ startAt: row.startAt,
20
+ endAt: row.endAt,
21
+ allDay: row.allDay,
22
+ createdAt: row.createdAt,
23
+ updatedAt: row.updatedAt,
24
+ };
25
+ }
26
+
27
+ export class CalendarEventIndexRepo implements ICalendarEventIndexRepository {
28
+ constructor(private db: DB) {}
29
+
30
+ async replaceForObject(
31
+ calendarId: string,
32
+ calendarObjectId: string,
33
+ occurrences: CalendarOccurrenceInput[],
34
+ ): Promise<void> {
35
+ await this.deleteForObject(calendarId, calendarObjectId);
36
+ if (occurrences.length === 0) return;
37
+
38
+ const now = Date.now();
39
+ await this.db.insert(calendarEventIndexTable).values(
40
+ occurrences.map((occurrence) => ({
41
+ calendarId,
42
+ calendarObjectId,
43
+ recurrenceId: occurrence.recurrenceId,
44
+ startAt: occurrence.startAt,
45
+ endAt: occurrence.endAt,
46
+ allDay: occurrence.allDay,
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ })),
50
+ );
51
+ }
52
+
53
+ async deleteForObject(
54
+ calendarId: string,
55
+ calendarObjectId: string,
56
+ ): Promise<void> {
57
+ await this.db
58
+ .delete(calendarEventIndexTable)
59
+ .where(
60
+ and(
61
+ eq(calendarEventIndexTable.calendarId, calendarId),
62
+ eq(calendarEventIndexTable.calendarObjectId, calendarObjectId),
63
+ ),
64
+ );
65
+ }
66
+
67
+ async listForObject(
68
+ calendarId: string,
69
+ calendarObjectId: string,
70
+ ): Promise<CalendarEventIndexItem[]> {
71
+ const rows = await this.db
72
+ .select()
73
+ .from(calendarEventIndexTable)
74
+ .where(
75
+ and(
76
+ eq(calendarEventIndexTable.calendarId, calendarId),
77
+ eq(calendarEventIndexTable.calendarObjectId, calendarObjectId),
78
+ ),
79
+ )
80
+ .orderBy(asc(calendarEventIndexTable.startAt));
81
+ return rows.map(rowToOccurrence);
82
+ }
83
+
84
+ async listByStartRange(
85
+ calendarId: string,
86
+ startAt: string,
87
+ endAt: string,
88
+ ): Promise<CalendarEventIndexItem[]> {
89
+ const rows = await this.db
90
+ .select()
91
+ .from(calendarEventIndexTable)
92
+ .where(
93
+ and(
94
+ eq(calendarEventIndexTable.calendarId, calendarId),
95
+ gte(calendarEventIndexTable.startAt, startAt),
96
+ lt(calendarEventIndexTable.startAt, endAt),
97
+ ),
98
+ )
99
+ .orderBy(asc(calendarEventIndexTable.startAt));
100
+ return rows.map(rowToOccurrence);
101
+ }
102
+ }
@@ -0,0 +1,23 @@
1
+ import { calendarObjectRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { calendarObjectTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { CalendarObjectRepo } from "./calendar-object.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ calendarObjectRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ calendarObjects: calendarObjectTable,
14
+ });
15
+ close = closeDb;
16
+ return new CalendarObjectRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -0,0 +1,158 @@
1
+ import type {
2
+ CalendarObjectItem,
3
+ ICalendarObjectRepository,
4
+ PutCalendarObjectInput,
5
+ } from "@remit/data-ports";
6
+ import { deriveCalendarObjectId } from "@remit/data-ports/id";
7
+ import { and, asc, eq, gt } from "drizzle-orm";
8
+ import type { Db } from "../db.js";
9
+ import { NotFoundError } from "../error.js";
10
+ import { calendarObjectTable } from "../schema.js";
11
+
12
+ type DB = Db<Record<string, unknown>>;
13
+
14
+ function rowToCalendarObject(
15
+ row: typeof calendarObjectTable.$inferSelect,
16
+ ): CalendarObjectItem {
17
+ return {
18
+ calendarObjectId: row.calendarObjectId,
19
+ calendarId: row.calendarId,
20
+ resourceName: row.resourceName,
21
+ icalUid: row.icalUid,
22
+ icalData: row.icalData,
23
+ etag: row.etag,
24
+ sequence: row.sequence,
25
+ syncSequence: row.syncSequence,
26
+ summary: row.summary,
27
+ dtStart: row.dtStart,
28
+ dtEnd: row.dtEnd,
29
+ allDay: row.allDay,
30
+ zoneCertainty: row.zoneCertainty,
31
+ status: row.status,
32
+ transparency: row.transparency,
33
+ hasRecurrence: row.hasRecurrence,
34
+ expandedThrough: row.expandedThrough,
35
+ createdAt: row.createdAt,
36
+ updatedAt: row.updatedAt,
37
+ };
38
+ }
39
+
40
+ export class CalendarObjectRepo implements ICalendarObjectRepository {
41
+ constructor(private db: DB) {}
42
+
43
+ async put(input: PutCalendarObjectInput): Promise<CalendarObjectItem> {
44
+ const now = Date.now();
45
+ const calendarObjectId = deriveCalendarObjectId(
46
+ input.calendarId,
47
+ input.resourceName,
48
+ );
49
+ const values = {
50
+ ...input,
51
+ calendarObjectId,
52
+ createdAt: now,
53
+ updatedAt: now,
54
+ };
55
+ const [row] = await this.db
56
+ .insert(calendarObjectTable)
57
+ .values(values)
58
+ .onConflictDoUpdate({
59
+ target: calendarObjectTable.calendarObjectId,
60
+ set: { ...input, updatedAt: now },
61
+ })
62
+ .returning();
63
+ return rowToCalendarObject(row);
64
+ }
65
+
66
+ async get(
67
+ calendarId: string,
68
+ calendarObjectId: string,
69
+ ): Promise<CalendarObjectItem> {
70
+ const [row] = await this.db
71
+ .select()
72
+ .from(calendarObjectTable)
73
+ .where(
74
+ and(
75
+ eq(calendarObjectTable.calendarId, calendarId),
76
+ eq(calendarObjectTable.calendarObjectId, calendarObjectId),
77
+ ),
78
+ );
79
+ if (!row) {
80
+ throw new NotFoundError(`Calendar object not found: ${calendarObjectId}`);
81
+ }
82
+ return rowToCalendarObject(row);
83
+ }
84
+
85
+ async delete(calendarId: string, calendarObjectId: string): Promise<void> {
86
+ await this.db
87
+ .delete(calendarObjectTable)
88
+ .where(
89
+ and(
90
+ eq(calendarObjectTable.calendarId, calendarId),
91
+ eq(calendarObjectTable.calendarObjectId, calendarObjectId),
92
+ ),
93
+ );
94
+ }
95
+
96
+ /**
97
+ * A primary-key read, not a scan: `calendarObjectId` is derived from
98
+ * `(calendarId, resourceName)`, so the name a client PUT to computes
99
+ * straight to the row it wrote.
100
+ */
101
+ async findByResourceName(
102
+ calendarId: string,
103
+ resourceName: string,
104
+ ): Promise<CalendarObjectItem | null> {
105
+ const [row] = await this.db
106
+ .select()
107
+ .from(calendarObjectTable)
108
+ .where(
109
+ eq(
110
+ calendarObjectTable.calendarObjectId,
111
+ deriveCalendarObjectId(calendarId, resourceName),
112
+ ),
113
+ );
114
+ return row ? rowToCalendarObject(row) : null;
115
+ }
116
+
117
+ async findByUid(
118
+ calendarId: string,
119
+ icalUid: string,
120
+ ): Promise<CalendarObjectItem | null> {
121
+ const [row] = await this.db
122
+ .select()
123
+ .from(calendarObjectTable)
124
+ .where(
125
+ and(
126
+ eq(calendarObjectTable.calendarId, calendarId),
127
+ eq(calendarObjectTable.icalUid, icalUid),
128
+ ),
129
+ );
130
+ return row ? rowToCalendarObject(row) : null;
131
+ }
132
+
133
+ async listByCalendar(calendarId: string): Promise<CalendarObjectItem[]> {
134
+ const rows = await this.db
135
+ .select()
136
+ .from(calendarObjectTable)
137
+ .where(eq(calendarObjectTable.calendarId, calendarId))
138
+ .orderBy(asc(calendarObjectTable.resourceName));
139
+ return rows.map(rowToCalendarObject);
140
+ }
141
+
142
+ async listChangedSince(
143
+ calendarId: string,
144
+ syncSequence: number,
145
+ ): Promise<CalendarObjectItem[]> {
146
+ const rows = await this.db
147
+ .select()
148
+ .from(calendarObjectTable)
149
+ .where(
150
+ and(
151
+ eq(calendarObjectTable.calendarId, calendarId),
152
+ gt(calendarObjectTable.syncSequence, syncSequence),
153
+ ),
154
+ )
155
+ .orderBy(asc(calendarObjectTable.syncSequence));
156
+ return rows.map(rowToCalendarObject);
157
+ }
158
+ }
@@ -0,0 +1,205 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import {
4
+ provisionDefaultCalendar,
5
+ putCalendarObject,
6
+ } from "@remit/calendar-service";
7
+ import type {
8
+ CalendarUnitOfWorkRepositories,
9
+ ICalendarUnitOfWork,
10
+ } from "@remit/data-ports";
11
+ import type { Db } from "../db.js";
12
+ import {
13
+ calendarEventIndexTable,
14
+ calendarObjectTable,
15
+ calendarTable,
16
+ } from "../schema.js";
17
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
18
+ import { DrizzleCalendarUnitOfWork } from "./calendar-unit-of-work.js";
19
+
20
+ const ACCOUNT_CONFIG_ID = "account-config-1";
21
+
22
+ const RESOURCE = [
23
+ "BEGIN:VCALENDAR",
24
+ "VERSION:2.0",
25
+ "BEGIN:VEVENT",
26
+ "UID:atomic@example.com",
27
+ "DTSTART:20260826T090000Z",
28
+ "DTEND:20260826T100000Z",
29
+ "SUMMARY:Quarterly review",
30
+ "RRULE:FREQ=WEEKLY;COUNT=3",
31
+ "END:VEVENT",
32
+ "END:VCALENDAR",
33
+ "",
34
+ ].join("\r\n");
35
+
36
+ /**
37
+ * Fails the third of the write set's three writes. The occurrence rows are
38
+ * written last, so a failure there is the case that proves the boundary: the
39
+ * object row and the sequence bump are already in the transaction when it
40
+ * happens.
41
+ */
42
+ class FailingIndexUnitOfWork implements ICalendarUnitOfWork {
43
+ constructor(private inner: ICalendarUnitOfWork) {}
44
+
45
+ transaction<T>(
46
+ fn: (repos: CalendarUnitOfWorkRepositories) => Promise<T>,
47
+ ): Promise<T> {
48
+ return this.inner.transaction((repos) =>
49
+ fn({
50
+ ...repos,
51
+ calendarEventIndex: {
52
+ ...repos.calendarEventIndex,
53
+ replaceForObject: () => {
54
+ throw new Error("the index write failed");
55
+ },
56
+ },
57
+ }),
58
+ );
59
+ }
60
+ }
61
+
62
+ describe("the calendar write path against sqlite", () => {
63
+ let db: Db<Record<string, unknown>>;
64
+ let close: () => Promise<void>;
65
+ let unitOfWork: ICalendarUnitOfWork;
66
+ let calendarId: string;
67
+
68
+ before(async () => {
69
+ const created = await createSqliteTestDb({
70
+ calendars: calendarTable,
71
+ calendarObjects: calendarObjectTable,
72
+ calendarEventIndexes: calendarEventIndexTable,
73
+ });
74
+ db = created.db as unknown as Db<Record<string, unknown>>;
75
+ close = created.close;
76
+ unitOfWork = new DrizzleCalendarUnitOfWork(db);
77
+ const collection = await provisionDefaultCalendar(
78
+ unitOfWork,
79
+ ACCOUNT_CONFIG_ID,
80
+ );
81
+ calendarId = collection.calendarId;
82
+ });
83
+
84
+ after(() => close());
85
+
86
+ const objects = () => db.select().from(calendarObjectTable);
87
+ const occurrences = () => db.select().from(calendarEventIndexTable);
88
+
89
+ test("writes the object, its occurrences and the sequence bump together", async () => {
90
+ const result = await putCalendarObject(unitOfWork, {
91
+ accountConfigId: ACCOUNT_CONFIG_ID,
92
+ calendarId,
93
+ resourceName: "review.ics",
94
+ icalData: RESOURCE,
95
+ });
96
+
97
+ assert.ok(result.ok);
98
+ assert.equal(result.value.icalData, RESOURCE);
99
+ assert.equal(
100
+ (await objects())[0]?.icalData,
101
+ RESOURCE,
102
+ "the stored row holds the input bytes, CRLF and all",
103
+ );
104
+ assert.equal(result.value.syncSequence, 1);
105
+ assert.equal((await objects()).length, 1);
106
+ assert.equal(
107
+ (await occurrences()).filter(
108
+ (row) => row.calendarObjectId === result.value.calendarObjectId,
109
+ ).length,
110
+ 3,
111
+ );
112
+ });
113
+
114
+ test("a failing index write leaves no object behind", async () => {
115
+ const before = await objects();
116
+
117
+ await assert.rejects(
118
+ putCalendarObject(new FailingIndexUnitOfWork(unitOfWork), {
119
+ accountConfigId: ACCOUNT_CONFIG_ID,
120
+ calendarId,
121
+ resourceName: "doomed.ics",
122
+ icalData: RESOURCE,
123
+ }),
124
+ /the index write failed/,
125
+ );
126
+
127
+ const after = await objects();
128
+ assert.equal(after.length, before.length);
129
+ assert.equal(
130
+ after.some((row) => row.resourceName === "doomed.ics"),
131
+ false,
132
+ );
133
+ });
134
+
135
+ test("a failing index write leaves the collection's sequence where it was", async () => {
136
+ const [before] = await db.select().from(calendarTable);
137
+
138
+ await assert.rejects(
139
+ putCalendarObject(new FailingIndexUnitOfWork(unitOfWork), {
140
+ accountConfigId: ACCOUNT_CONFIG_ID,
141
+ calendarId,
142
+ resourceName: "doomed-again.ics",
143
+ icalData: RESOURCE,
144
+ }),
145
+ /the index write failed/,
146
+ );
147
+
148
+ const [after] = await db.select().from(calendarTable);
149
+ assert.equal(after?.syncSequence, before?.syncSequence);
150
+ });
151
+
152
+ test("a refused resource writes nothing at all", async () => {
153
+ const before = await objects();
154
+ const [collectionBefore] = await db.select().from(calendarTable);
155
+
156
+ const result = await putCalendarObject(unitOfWork, {
157
+ accountConfigId: ACCOUNT_CONFIG_ID,
158
+ calendarId,
159
+ resourceName: "backwards.ics",
160
+ icalData: [
161
+ "BEGIN:VCALENDAR",
162
+ "VERSION:2.0",
163
+ "BEGIN:VEVENT",
164
+ "UID:backwards@example.com",
165
+ "DTSTART:20260826T100000Z",
166
+ "DTEND:20260826T090000Z",
167
+ "END:VEVENT",
168
+ "END:VCALENDAR",
169
+ "",
170
+ ].join("\r\n"),
171
+ });
172
+
173
+ assert.ok(!result.ok);
174
+ assert.equal(result.error.code, "BackwardsEnd");
175
+ assert.equal((await objects()).length, before.length);
176
+ const [collectionAfter] = await db.select().from(calendarTable);
177
+ assert.equal(collectionAfter?.syncSequence, collectionBefore?.syncSequence);
178
+ });
179
+
180
+ test("rewriting a resource replaces its occurrences rather than adding to them", async () => {
181
+ const first = await putCalendarObject(unitOfWork, {
182
+ accountConfigId: ACCOUNT_CONFIG_ID,
183
+ calendarId,
184
+ resourceName: "shrinking.ics",
185
+ icalData: RESOURCE,
186
+ });
187
+ assert.ok(first.ok);
188
+
189
+ const second = await putCalendarObject(unitOfWork, {
190
+ accountConfigId: ACCOUNT_CONFIG_ID,
191
+ calendarId,
192
+ resourceName: "shrinking.ics",
193
+ icalData: RESOURCE.replace("COUNT=3", "COUNT=1"),
194
+ });
195
+ assert.ok(second.ok);
196
+
197
+ assert.equal(second.value.calendarObjectId, first.value.calendarObjectId);
198
+ assert.equal(
199
+ (await occurrences()).filter(
200
+ (row) => row.calendarObjectId === second.value.calendarObjectId,
201
+ ).length,
202
+ 1,
203
+ );
204
+ });
205
+ });
@@ -0,0 +1,27 @@
1
+ import type {
2
+ CalendarUnitOfWorkRepositories,
3
+ ICalendarUnitOfWork,
4
+ } from "@remit/data-ports";
5
+ import type { Db } from "../db.js";
6
+ import { runInTransaction } from "../tx.js";
7
+ import { CalendarCollectionRepo } from "./calendar-collection.js";
8
+ import { CalendarEventIndexRepo } from "./calendar-event-index.js";
9
+ import { CalendarObjectRepo } from "./calendar-object.js";
10
+
11
+ type DB = Db<Record<string, unknown>>;
12
+
13
+ export class DrizzleCalendarUnitOfWork implements ICalendarUnitOfWork {
14
+ constructor(private db: DB) {}
15
+
16
+ transaction<T>(
17
+ fn: (repos: CalendarUnitOfWorkRepositories) => Promise<T>,
18
+ ): Promise<T> {
19
+ return runInTransaction(this.db, (tx) =>
20
+ fn({
21
+ calendarCollection: new CalendarCollectionRepo(tx as never),
22
+ calendarObject: new CalendarObjectRepo(tx as never),
23
+ calendarEventIndex: new CalendarEventIndexRepo(tx as never),
24
+ }),
25
+ );
26
+ }
27
+ }
package/src/schema.ts CHANGED
@@ -8,6 +8,9 @@
8
8
 
9
9
  import * as entities from "@remit/drizzle-sqlite-schema";
10
10
 
11
+ export const calendarTable = entities.calendarCollections;
12
+ export const calendarObjectTable = entities.calendarObjects;
13
+ export const calendarEventIndexTable = entities.calendarEventIndexes;
11
14
  export const filterAnchorTable = entities.filterAnchors;
12
15
  export const filterTable = entities.filters;
13
16
  export const labelTable = entities.labels;