@remit/drizzle-service 0.0.69 → 0.0.71

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.69",
3
+ "version": "0.0.71",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { CreateFailedConflictError, NotFoundError } from "./error.js";
2
2
  export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
3
3
  export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
4
+ export { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
4
5
  export { CalendarObjectRepo } from "./repos/calendar-object.js";
5
6
  export { CalendarSuggestionRepo } from "./repos/calendar-suggestion.js";
6
7
  export { DrizzleCalendarUnitOfWork } from "./repos/calendar-unit-of-work.js";
@@ -0,0 +1,23 @@
1
+ import { calendarFeedTokenRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { calendarFeedTokenTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { CalendarFeedTokenRepo } from "./calendar-feed-token.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ calendarFeedTokenRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ calendarFeedTokens: calendarFeedTokenTable,
14
+ });
15
+ close = closeDb;
16
+ return new CalendarFeedTokenRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -0,0 +1,97 @@
1
+ import type {
2
+ CalendarFeedTokenItem,
3
+ ICalendarFeedTokenRepository,
4
+ PutCalendarFeedTokenInput,
5
+ } from "@remit/data-ports";
6
+ import { deriveCalendarFeedTokenId } from "@remit/data-ports/id";
7
+ import { and, eq } from "drizzle-orm";
8
+ import type { Db } from "../db.js";
9
+ import { calendarFeedTokenTable } from "../schema.js";
10
+
11
+ type DB = Db<Record<string, unknown>>;
12
+
13
+ function rowToFeedToken(
14
+ row: typeof calendarFeedTokenTable.$inferSelect,
15
+ ): CalendarFeedTokenItem {
16
+ return {
17
+ feedTokenId: row.feedTokenId,
18
+ accountConfigId: row.accountConfigId,
19
+ calendarId: row.calendarId,
20
+ tokenHash: row.tokenHash,
21
+ createdAt: row.createdAt,
22
+ rotatedAt: row.rotatedAt,
23
+ updatedAt: row.updatedAt,
24
+ };
25
+ }
26
+
27
+ export class CalendarFeedTokenRepo implements ICalendarFeedTokenRepository {
28
+ constructor(private db: DB) {}
29
+
30
+ /**
31
+ * One statement, so a rotation can never be observed as two live addresses.
32
+ * The conflict target is the derived primary key, which is what makes "one
33
+ * active token per calendar" a property of the schema rather than of this
34
+ * method: `createdAt` is left where it was and `rotatedAt` takes the clock,
35
+ * so the row itself says whether this address is the calendar's first.
36
+ */
37
+ async put(input: PutCalendarFeedTokenInput): Promise<CalendarFeedTokenItem> {
38
+ const now = Date.now();
39
+ const [row] = await this.db
40
+ .insert(calendarFeedTokenTable)
41
+ .values({
42
+ feedTokenId: deriveCalendarFeedTokenId(input.calendarId),
43
+ accountConfigId: input.accountConfigId,
44
+ calendarId: input.calendarId,
45
+ tokenHash: input.tokenHash,
46
+ createdAt: now,
47
+ rotatedAt: 0,
48
+ updatedAt: now,
49
+ })
50
+ .onConflictDoUpdate({
51
+ target: [
52
+ calendarFeedTokenTable.accountConfigId,
53
+ calendarFeedTokenTable.feedTokenId,
54
+ ],
55
+ set: { tokenHash: input.tokenHash, rotatedAt: now, updatedAt: now },
56
+ })
57
+ .returning();
58
+ return rowToFeedToken(row);
59
+ }
60
+
61
+ async findByCalendar(
62
+ accountConfigId: string,
63
+ calendarId: string,
64
+ ): Promise<CalendarFeedTokenItem | null> {
65
+ const [row] = await this.db
66
+ .select()
67
+ .from(calendarFeedTokenTable)
68
+ .where(
69
+ and(
70
+ eq(calendarFeedTokenTable.accountConfigId, accountConfigId),
71
+ eq(calendarFeedTokenTable.calendarId, calendarId),
72
+ ),
73
+ );
74
+ return row ? rowToFeedToken(row) : null;
75
+ }
76
+
77
+ async findByTokenHash(
78
+ tokenHash: string,
79
+ ): Promise<CalendarFeedTokenItem | null> {
80
+ const [row] = await this.db
81
+ .select()
82
+ .from(calendarFeedTokenTable)
83
+ .where(eq(calendarFeedTokenTable.tokenHash, tokenHash));
84
+ return row ? rowToFeedToken(row) : null;
85
+ }
86
+
87
+ async delete(accountConfigId: string, calendarId: string): Promise<void> {
88
+ await this.db
89
+ .delete(calendarFeedTokenTable)
90
+ .where(
91
+ and(
92
+ eq(calendarFeedTokenTable.accountConfigId, accountConfigId),
93
+ eq(calendarFeedTokenTable.calendarId, calendarId),
94
+ ),
95
+ );
96
+ }
97
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The calendar tables against the shape a deployment actually runs: every
3
+ * committed entity migration applied in journal order to an empty database,
4
+ * rather than a schema pushed from the drizzle table objects.
5
+ *
6
+ * Nothing else ran these files. Every calendar SQLite test builds its database
7
+ * with `createSqliteTestDb`, which regenerates the DDL from the table objects on
8
+ * each run, and the drift guard only diffs the latest snapshot — so a migration
9
+ * that fails to apply, or applies to a different shape than its snapshot claims,
10
+ * passes the whole suite while a self-host upgrade breaks. That is reader#73 one
11
+ * layer up: there the shipped DDL was wrong, here the shipped DDL was never
12
+ * executed at all.
13
+ *
14
+ * Everything below walks the journal, so a migration added tomorrow is covered
15
+ * the moment it is committed.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { after, before, describe, test } from "node:test";
20
+ import {
21
+ CalendarEventStatus,
22
+ CalendarInviteMethod,
23
+ CalendarSuggestionSource,
24
+ CalendarSuggestionState,
25
+ CalendarTransparency,
26
+ ZoneCertainty,
27
+ } from "@remit/domain-enums";
28
+ import Database from "better-sqlite3";
29
+ import { drizzle } from "drizzle-orm/better-sqlite3";
30
+ import type { Db } from "../db.js";
31
+ import {
32
+ calendarEventIndexTable,
33
+ calendarObjectTable,
34
+ calendarSuggestionTable,
35
+ calendarTable,
36
+ } from "../schema.js";
37
+ import {
38
+ applyMigration,
39
+ migrationJournal,
40
+ migrationSnapshot,
41
+ migrationTagsOnDisk,
42
+ } from "../test-shipped-sqlite-schema.js";
43
+ import { CalendarCollectionRepo } from "./calendar-collection.js";
44
+ import { CalendarEventIndexRepo } from "./calendar-event-index.js";
45
+ import { CalendarObjectRepo } from "./calendar-object.js";
46
+ import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
47
+
48
+ const ACCOUNT_CONFIG_ID = "account-config-shipped";
49
+ const ROOT_SNAPSHOT_PREV_ID = "00000000-0000-0000-0000-000000000000";
50
+
51
+ const ICAL_DATA = [
52
+ "BEGIN:VCALENDAR",
53
+ "VERSION:2.0",
54
+ "BEGIN:VEVENT",
55
+ "UID:shipped@example.test",
56
+ "DTSTART:20260901T080000Z",
57
+ "DTEND:20260901T090000Z",
58
+ "SUMMARY:Shipped-migrations round trip",
59
+ "END:VEVENT",
60
+ "END:VCALENDAR",
61
+ "",
62
+ ].join("\r\n");
63
+
64
+ describe("the committed sqlite migration journal", () => {
65
+ const entries = migrationJournal();
66
+
67
+ test("names each migration once, in a strictly rising order", () => {
68
+ // Not "consecutive": drizzle-kit skipped 0009, and a withdrawn number is
69
+ // harmless. A repeated or reordered one is not — two branches generating
70
+ // against the same predecessor is the collision this pins.
71
+ for (const [position, entry] of entries.entries()) {
72
+ const previous = entries[position - 1];
73
+ assert.ok(
74
+ previous === undefined || entry.idx > previous.idx,
75
+ `journal entry ${entry.tag} does not follow ${previous?.tag}`,
76
+ );
77
+ assert.equal(
78
+ entry.tag.slice(0, 4),
79
+ String(entry.idx).padStart(4, "0"),
80
+ `journal entry ${entry.tag} is filed under idx ${entry.idx}`,
81
+ );
82
+ }
83
+ });
84
+
85
+ test("accounts for every migration file on disk", () => {
86
+ // An orphan .sql is a migration a deployment never runs; a journal entry
87
+ // with no file is one that crashes the migrator on the next upgrade.
88
+ assert.deepEqual(
89
+ migrationTagsOnDisk(),
90
+ entries.map((entry) => entry.tag).sort(),
91
+ );
92
+ });
93
+
94
+ test("chains every snapshot to its predecessor", () => {
95
+ for (const [position, entry] of entries.entries()) {
96
+ const snapshot = migrationSnapshot(entry.idx);
97
+ const previous = entries[position - 1];
98
+ assert.equal(
99
+ snapshot.prevId,
100
+ previous === undefined
101
+ ? ROOT_SNAPSHOT_PREV_ID
102
+ : migrationSnapshot(previous.idx).id,
103
+ `snapshot ${entry.tag} does not follow ${previous?.tag ?? "the root"}`,
104
+ );
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("the calendar tables under the shipped migrations", () => {
110
+ let sqlite: Database.Database;
111
+ let db: Db<Record<string, unknown>>;
112
+
113
+ before(() => {
114
+ sqlite = new Database(":memory:");
115
+ for (const entry of migrationJournal()) {
116
+ applyMigration(sqlite, entry.tag);
117
+ }
118
+ sqlite.pragma("foreign_keys = ON");
119
+ db = drizzle(sqlite, {
120
+ schema: {
121
+ calendars: calendarTable,
122
+ calendarObjects: calendarObjectTable,
123
+ calendarEventIndexes: calendarEventIndexTable,
124
+ calendarSuggestions: calendarSuggestionTable,
125
+ },
126
+ }) as unknown as Db<Record<string, unknown>>;
127
+ });
128
+
129
+ after(() => {
130
+ sqlite.close();
131
+ });
132
+
133
+ test("leaves all four calendar tables behind", () => {
134
+ const present = new Set(
135
+ sqlite
136
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
137
+ .all()
138
+ .map((row) => (row as { name: string }).name),
139
+ );
140
+
141
+ for (const table of [
142
+ "calendar",
143
+ "calendar_object",
144
+ "calendar_event_index",
145
+ "calendar_suggestion",
146
+ ]) {
147
+ assert.ok(present.has(table), `${table} was never created`);
148
+ }
149
+ });
150
+
151
+ test("round-trips a collection", async () => {
152
+ const repo = new CalendarCollectionRepo(db);
153
+
154
+ const created = await repo.create({
155
+ accountConfigId: ACCOUNT_CONFIG_ID,
156
+ urlSegment: "shipped",
157
+ displayName: "Shipped",
158
+ });
159
+
160
+ const fetched = await repo.get(ACCOUNT_CONFIG_ID, created.calendarId);
161
+ assert.equal(fetched.displayName, "Shipped");
162
+ assert.equal(fetched.urlSegment, "shipped");
163
+ assert.equal(fetched.syncSequence, 0);
164
+ });
165
+
166
+ test("round-trips an object and its occurrence", async () => {
167
+ const collection = await new CalendarCollectionRepo(db).create({
168
+ accountConfigId: ACCOUNT_CONFIG_ID,
169
+ urlSegment: "objects",
170
+ displayName: "Objects",
171
+ });
172
+ const objects = new CalendarObjectRepo(db);
173
+
174
+ const written = await objects.put({
175
+ calendarId: collection.calendarId,
176
+ resourceName: "shipped.ics",
177
+ icalUid: "shipped@example.test",
178
+ icalData: ICAL_DATA,
179
+ etag: "b".repeat(64),
180
+ sequence: 0,
181
+ syncSequence: 1,
182
+ summary: "Shipped-migrations round trip",
183
+ dtStart: "2026-09-01T08:00:00+00:00",
184
+ dtEnd: "2026-09-01T09:00:00+00:00",
185
+ allDay: false,
186
+ zoneCertainty: ZoneCertainty.Explicit,
187
+ status: CalendarEventStatus.Confirmed,
188
+ transparency: CalendarTransparency.Opaque,
189
+ hasRecurrence: false,
190
+ expandedThrough: "",
191
+ });
192
+
193
+ const fetched = await objects.get(
194
+ collection.calendarId,
195
+ written.calendarObjectId,
196
+ );
197
+ assert.equal(fetched.icalData, ICAL_DATA);
198
+ assert.equal(fetched.summary, "Shipped-migrations round trip");
199
+ assert.equal(fetched.allDay, false);
200
+
201
+ const index = new CalendarEventIndexRepo(db);
202
+ await index.replaceForObject(
203
+ collection.calendarId,
204
+ written.calendarObjectId,
205
+ [
206
+ {
207
+ recurrenceId: "",
208
+ startAt: "2026-09-01T08:00:00Z",
209
+ endAt: "2026-09-01T09:00:00Z",
210
+ allDay: false,
211
+ // The three columns 0018 added. A migration that never ran leaves
212
+ // the repo selecting columns the table does not have.
213
+ summary: "Shipped-migrations round trip",
214
+ status: CalendarEventStatus.Confirmed,
215
+ transparency: CalendarTransparency.Opaque,
216
+ },
217
+ ],
218
+ );
219
+
220
+ const occurrences = await index.listForObject(
221
+ collection.calendarId,
222
+ written.calendarObjectId,
223
+ );
224
+ assert.equal(occurrences.length, 1);
225
+ assert.equal(occurrences[0]?.startAt, "2026-09-01T08:00:00Z");
226
+ assert.equal(occurrences[0]?.summary, "Shipped-migrations round trip");
227
+ assert.equal(occurrences[0]?.status, CalendarEventStatus.Confirmed);
228
+ assert.equal(occurrences[0]?.transparency, CalendarTransparency.Opaque);
229
+ });
230
+
231
+ test("round-trips a suggestion", async () => {
232
+ const repo = new CalendarSuggestionRepo(db);
233
+
234
+ const written = await repo.put({
235
+ accountConfigId: ACCOUNT_CONFIG_ID,
236
+ messageId: "message-1",
237
+ bodyPartId: "body-part-1",
238
+ icalUid: "invite@example.test",
239
+ sequence: 0,
240
+ method: CalendarInviteMethod.Request,
241
+ source: CalendarSuggestionSource.IcalendarPart,
242
+ summary: "Design review",
243
+ dtStart: "2026-09-01T10:00:00+02:00",
244
+ dtEnd: "2026-09-01T11:00:00+02:00",
245
+ allDay: false,
246
+ location: "",
247
+ organizer: "organizer@example.test",
248
+ zoneCertainty: ZoneCertainty.Explicit,
249
+ icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
250
+ });
251
+
252
+ const fetched = await repo.get(ACCOUNT_CONFIG_ID, written.suggestionId);
253
+ assert.equal(fetched.summary, "Design review");
254
+ assert.equal(fetched.state, CalendarSuggestionState.Pending);
255
+ assert.equal(fetched.acceptedCalendarObjectId, "");
256
+ assert.equal(fetched.organizer, "organizer@example.test");
257
+ });
258
+ });
@@ -6,6 +6,7 @@ import type { Db } from "../db.js";
6
6
  import { runInTransaction } from "../tx.js";
7
7
  import { CalendarCollectionRepo } from "./calendar-collection.js";
8
8
  import { CalendarEventIndexRepo } from "./calendar-event-index.js";
9
+ import { CalendarFeedTokenRepo } from "./calendar-feed-token.js";
9
10
  import { CalendarObjectRepo } from "./calendar-object.js";
10
11
  import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
11
12
 
@@ -23,6 +24,7 @@ export class DrizzleCalendarUnitOfWork implements ICalendarUnitOfWork {
23
24
  calendarObject: new CalendarObjectRepo(tx as never),
24
25
  calendarEventIndex: new CalendarEventIndexRepo(tx as never),
25
26
  calendarSuggestion: new CalendarSuggestionRepo(tx as never),
27
+ calendarFeedToken: new CalendarFeedTokenRepo(tx as never),
26
28
  }),
27
29
  );
28
30
  }
package/src/schema.ts CHANGED
@@ -11,6 +11,7 @@ import * as entities from "@remit/drizzle-sqlite-schema";
11
11
  export const calendarTable = entities.calendarCollections;
12
12
  export const calendarObjectTable = entities.calendarObjects;
13
13
  export const calendarEventIndexTable = entities.calendarEventIndexes;
14
+ export const calendarFeedTokenTable = entities.calendarFeedTokens;
14
15
  export const calendarSuggestionTable = entities.calendarSuggestions;
15
16
  export const filterAnchorTable = entities.filterAnchors;
16
17
  export const filterTable = entities.filters;
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readdirSync, readFileSync } from "node:fs";
2
2
  import type Database from "better-sqlite3";
3
3
 
4
4
  // Read the committed SQLite entity migrations — the DDL a self-host deployment
@@ -17,6 +17,42 @@ const MIGRATIONS_DIR = new URL(
17
17
  export const migrationSql = (tag: string): string =>
18
18
  readFileSync(new URL(`${tag}.sql`, MIGRATIONS_DIR), "utf8");
19
19
 
20
+ export interface MigrationJournalEntry {
21
+ idx: number;
22
+ tag: string;
23
+ }
24
+
25
+ /** Every committed entity migration, in the order the migrator runs them. */
26
+ export const migrationJournal = (): MigrationJournalEntry[] =>
27
+ (
28
+ JSON.parse(
29
+ readFileSync(new URL("meta/_journal.json", MIGRATIONS_DIR), "utf8"),
30
+ ) as { entries: MigrationJournalEntry[] }
31
+ ).entries;
32
+
33
+ /** The `.sql` files present on disk, whatever the journal says about them. */
34
+ export const migrationTagsOnDisk = (): string[] =>
35
+ readdirSync(MIGRATIONS_DIR)
36
+ .filter((name) => name.endsWith(".sql"))
37
+ .map((name) => name.slice(0, -".sql".length))
38
+ .sort();
39
+
40
+ export interface MigrationSnapshot {
41
+ id: string;
42
+ prevId: string;
43
+ }
44
+
45
+ export const migrationSnapshot = (idx: number): MigrationSnapshot =>
46
+ JSON.parse(
47
+ readFileSync(
48
+ new URL(
49
+ `meta/${String(idx).padStart(4, "0")}_snapshot.json`,
50
+ MIGRATIONS_DIR,
51
+ ),
52
+ "utf8",
53
+ ),
54
+ ) as MigrationSnapshot;
55
+
20
56
  /** The `CREATE TABLE` block for one table, as that migration declares it. */
21
57
  export const shippedTableDdl = (tag: string, table: string): string => {
22
58
  const match = migrationSql(tag).match(
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { after, before, describe, test } from "node:test";
6
+ import Database from "better-sqlite3";
7
+ import { sql } from "drizzle-orm";
8
+ import { drizzle } from "drizzle-orm/better-sqlite3";
9
+ import type { Db } from "./db.js";
10
+ import { runInTransaction } from "./tx.js";
11
+
12
+ /**
13
+ * A write set that reads before it writes, against the file four processes
14
+ * share (RFC 036 D3).
15
+ *
16
+ * SQLite gives a deferred transaction its write lock at its first write, and by
17
+ * then a unit that read first is holding a snapshot. Another process committing
18
+ * in that gap makes the upgrade impossible, and SQLite refuses it at once —
19
+ * `busy_timeout` waits for a lock, not for a snapshot that is already stale. The
20
+ * unit then dies with "database is locked" however long that timeout is, which
21
+ * is what answered a calendar write 500 while the imap-worker was syncing.
22
+ */
23
+
24
+ const PRAGMAS = [
25
+ "journal_mode = WAL",
26
+ "busy_timeout = 5000",
27
+ "synchronous = NORMAL",
28
+ "foreign_keys = ON",
29
+ ];
30
+
31
+ let directory: string;
32
+ let sqlite: Database.Database;
33
+ let other: Database.Database;
34
+ let db: Db<Record<string, never>>;
35
+
36
+ before(() => {
37
+ directory = mkdtempSync(join(tmpdir(), "remit-tx-"));
38
+ const path = join(directory, "remit.db");
39
+
40
+ sqlite = new Database(path);
41
+ for (const pragma of PRAGMAS) sqlite.pragma(pragma);
42
+ sqlite.exec("CREATE TABLE row (id INTEGER PRIMARY KEY, v TEXT NOT NULL)");
43
+ sqlite.exec("INSERT INTO row (id, v) VALUES (1, 'read'), (2, 'other')");
44
+
45
+ // The other writer, as a second connection. It refuses instead of waiting,
46
+ // so the run finishes in its own time whichever of the two holds the lock.
47
+ other = new Database(path);
48
+ other.pragma("busy_timeout = 0");
49
+
50
+ db = drizzle(sqlite) as unknown as Db<Record<string, never>>;
51
+ });
52
+
53
+ after(() => {
54
+ other.close();
55
+ sqlite.close();
56
+ rmSync(directory, { recursive: true, force: true });
57
+ });
58
+
59
+ describe("a top-level sqlite unit that reads before it writes", () => {
60
+ test("commits through another connection's write landing between the two", async () => {
61
+ const outcome = await runInTransaction(db, async (tx) => {
62
+ await tx.get(sql`SELECT v FROM row WHERE id = 1`);
63
+
64
+ // The other process, arriving in the gap an async write set leaves
65
+ // open. Whether its own write lands is not the claim here — only that
66
+ // this unit can still finish the one it came to make.
67
+ try {
68
+ other.prepare("UPDATE row SET v = 'moved' WHERE id = 2").run();
69
+ } catch {
70
+ // Refused because this unit holds the write lock, which is the
71
+ // outcome that keeps the unit below alive.
72
+ }
73
+
74
+ await tx.run(sql`UPDATE row SET v = 'written' WHERE id = 1`);
75
+ return "committed";
76
+ });
77
+
78
+ assert.equal(outcome, "committed");
79
+ assert.deepEqual(sqlite.prepare("SELECT v FROM row WHERE id = 1").get(), {
80
+ v: "written",
81
+ });
82
+ });
83
+ });
package/src/tx.ts CHANGED
@@ -7,9 +7,19 @@ import type { Db } from "./db.js";
7
7
  //
8
8
  // better-sqlite3's native transaction runner rejects a callback that returns a
9
9
  // promise, and the repos' write sets are async, so drizzle's own
10
- // `db.transaction()` cannot be used. The callback is bracketed with a SAVEPOINT
11
- // instead a savepoint opens a transaction when none is active and commits
12
- // when the outermost one is released.
10
+ // `db.transaction()` cannot be used. A top-level unit is bracketed with
11
+ // `BEGIN IMMEDIATE` / `COMMIT` instead, and a nested one with a SAVEPOINT.
12
+ //
13
+ // IMMEDIATE, not the deferred transaction a bare `BEGIN` or `SAVEPOINT` opens.
14
+ // A deferred transaction takes its write lock at its first write, and a unit
15
+ // that reads before it writes (every unit that checks a row before updating it)
16
+ // has by then taken a read snapshot. If another process commits in between,
17
+ // SQLite refuses the upgrade with SQLITE_BUSY_SNAPSHOT — and that refusal is
18
+ // immediate, because `busy_timeout` cannot wait out a snapshot that is already
19
+ // stale. Four processes write this file (RFC 036 D3), so the gap is real: it
20
+ // answered a calendar write 500 while the imap-worker was syncing. Taking the
21
+ // write lock at BEGIN closes it — an IMMEDIATE that meets another writer waits
22
+ // out the `busy_timeout` and then proceeds, which is what that timeout is for.
13
23
  //
14
24
  // All writers on this backend share one better-sqlite3 connection (RFC 036 D3),
15
25
  // and every query runs synchronously, but an async callback still yields the
@@ -61,6 +71,27 @@ async function runSqliteSavepoint<TSchema extends Record<string, unknown>, T>(
61
71
  }
62
72
  }
63
73
 
74
+ async function runSqliteImmediate<TSchema extends Record<string, unknown>, T>(
75
+ db: Db<TSchema>,
76
+ fn: (tx: Db<TSchema>) => Promise<T>,
77
+ ): Promise<T> {
78
+ const runner = db as unknown as { run: (query: SQL) => unknown };
79
+ runner.run(sql.raw("BEGIN IMMEDIATE"));
80
+ try {
81
+ const result = await fn(db);
82
+ runner.run(sql.raw("COMMIT"));
83
+ return result;
84
+ } catch (error) {
85
+ try {
86
+ runner.run(sql.raw("ROLLBACK"));
87
+ } catch {
88
+ // A failed rollback must not mask the error that caused it; surface
89
+ // the original below.
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+
64
95
  export async function runInTransaction<
65
96
  TSchema extends Record<string, unknown>,
66
97
  T,
@@ -72,7 +103,7 @@ export async function runInTransaction<
72
103
  }
73
104
 
74
105
  return serializeSqlite(() =>
75
- inSqliteTx.run(true, () => runSqliteSavepoint(db, fn)),
106
+ inSqliteTx.run(true, () => runSqliteImmediate(db, fn)),
76
107
  );
77
108
  }
78
109