@remit/drizzle-service 0.0.98 → 0.0.100

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.
Files changed (93) hide show
  1. package/package.json +1 -1
  2. package/src/batch.sqlite.test.ts +78 -0
  3. package/src/batch.ts +45 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +1 -4
  6. package/src/generated-enum.test.ts +8 -0
  7. package/src/generated-id.test.ts +19 -0
  8. package/src/id.test.ts +0 -8
  9. package/src/id.ts +0 -6
  10. package/src/index.ts +16 -6
  11. package/src/repair/address-display-name.sqlite.test.ts +3 -5
  12. package/src/repair/address-display-name.ts +8 -3
  13. package/src/repair/junk-only-address.sqlite.test.ts +8 -36
  14. package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
  15. package/src/repos/account.conformance.sqlite.test.ts +1 -1
  16. package/src/repos/calendar-default.sqlite.test.ts +6 -6
  17. package/src/repos/calendar-put.sqlite.test.ts +40 -39
  18. package/src/repos/calendar-store.ts +273 -0
  19. package/src/repos/cascade-delete.sqlite.test.ts +7 -9
  20. package/src/repos/cascade-delete.test.ts +6 -13
  21. package/src/repos/cascade-delete.ts +127 -97
  22. package/src/repos/envelope.test.ts +1 -17
  23. package/src/repos/envelope.ts +83 -67
  24. package/src/repos/filter.test.ts +22 -2
  25. package/src/repos/filter.ts +1 -5
  26. package/src/repos/i4-account-config.test.ts +2 -2
  27. package/src/repos/i4-account-config.ts +5 -2
  28. package/src/repos/i4-account.test.ts +6 -2
  29. package/src/repos/i4-account.ts +12 -56
  30. package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
  31. package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
  32. package/src/repos/i4-address.ts +58 -90
  33. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
  34. package/src/repos/i4-mailbox-special-use.ts +9 -6
  35. package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
  36. package/src/repos/i4-mailbox.test.ts +17 -62
  37. package/src/repos/i4-mailbox.ts +121 -94
  38. package/src/repos/i4-organize-job-request.ts +0 -4
  39. package/src/repos/i4-outbox-attachment.ts +62 -33
  40. package/src/repos/mappers.ts +0 -16
  41. package/src/repos/message.sqlite.test.ts +62 -102
  42. package/src/repos/message.test.ts +6 -167
  43. package/src/repos/message.ts +227 -218
  44. package/src/repos/sync-warning.ts +76 -0
  45. package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
  46. package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
  47. package/src/repos/thread-message.sqlite.test.ts +2 -1
  48. package/src/repos/thread-message.test.ts +17 -4
  49. package/src/repos/thread-message.ts +502 -285
  50. package/src/repos/thread-search-predicates.ts +24 -26
  51. package/src/schema/message-data.ts +4 -8
  52. package/src/schema/sync-warning.ts +3 -0
  53. package/src/schema-sets/app.ts +17 -0
  54. package/src/schema-sets/mail.ts +32 -0
  55. package/src/schema-sets/search.ts +5 -0
  56. package/src/schema-version-accounting.sqlite.test.ts +56 -43
  57. package/src/schema.ts +3 -4
  58. package/src/search/thread-message-search.sqlite.test.ts +131 -0
  59. package/src/search/thread-message-search.ts +271 -0
  60. package/src/sqlite-client.ts +5 -7
  61. package/src/sqlite-repositories.ts +75 -0
  62. package/src/test-db-sqlite.ts +5 -13
  63. package/src/test-shipped-sqlite-schema.ts +46 -45
  64. package/src/test-sqlite.ts +35 -11
  65. package/src/vps-migrations-drift.sqlite.test.ts +81 -15
  66. package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
  67. package/src/account-synced-services-migration.sqlite.test.ts +0 -56
  68. package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
  69. package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
  70. package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
  71. package/src/repair/duplicate-mailbox-mail.ts +0 -98
  72. package/src/repair/thread-message-category-contract.ts +0 -505
  73. package/src/repair/thread-message-category.sqlite.test.ts +0 -100
  74. package/src/repair/thread-message-category.ts +0 -415
  75. package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
  76. package/src/repos/calendar-unit-of-work.ts +0 -31
  77. package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
  78. package/src/repos/filter-anchor-transaction.test.ts +0 -136
  79. package/src/repos/filter-anchor-transaction.ts +0 -45
  80. package/src/repos/filter-anchor.test.ts +0 -131
  81. package/src/repos/filter-anchor.ts +0 -105
  82. package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
  83. package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
  84. package/src/repos/search-index-shape.test.ts +0 -82
  85. package/src/repos/search-index-shape.ts +0 -56
  86. package/src/repos/serialized-writes.sqlite.test.ts +0 -199
  87. package/src/repos/unit-of-work.sqlite.test.ts +0 -138
  88. package/src/repos/unit-of-work.test.ts +0 -109
  89. package/src/repos/unit-of-work.ts +0 -31
  90. package/src/schema/outbox.ts +0 -46
  91. package/src/schema-full-sqlite.ts +0 -13
  92. package/src/tx.sqlite.test.ts +0 -83
  93. package/src/tx.ts +0 -233
@@ -1,82 +0,0 @@
1
- /**
2
- * The guard that keeps an instance installed by an earlier build from running a
3
- * predicate its index cannot answer.
4
- *
5
- * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table, so
6
- * adding an indexed column changes nothing on a database that already has the
7
- * old one — and a MATCH naming that column raises rather than missing. The
8
- * migrator compares the shipped shape with the installed one and rebuilds.
9
- */
10
- import assert from "node:assert/strict";
11
- import { readFileSync } from "node:fs";
12
- import { describe, test } from "node:test";
13
- import {
14
- searchIndexColumns,
15
- searchIndexShapeIsCurrent,
16
- } from "./search-index-shape.js";
17
-
18
- const shipped = readFileSync(
19
- new URL("../../../../npm-scripts/sqlite-search-index.sql", import.meta.url),
20
- "utf8",
21
- );
22
-
23
- const installed = (columns: string[]): string =>
24
- `CREATE VIRTUAL TABLE thread_message_fts USING fts5(${columns.join(", ")}, content='thread_message', content_rowid='rowid', tokenize='trigram remove_diacritics 1')`;
25
-
26
- describe("searchIndexColumns", () => {
27
- test("reads the indexed columns and leaves the options out", () => {
28
- assert.deepEqual(searchIndexColumns(installed(["subject", "sender"])), [
29
- "subject",
30
- "sender",
31
- ]);
32
- });
33
-
34
- test("reads the shipped index, triggers and all", () => {
35
- assert.deepEqual(searchIndexColumns(shipped), [
36
- "subject",
37
- "sender",
38
- "body",
39
- ]);
40
- });
41
-
42
- test("a table that is not an fts5 index has no columns", () => {
43
- assert.deepEqual(searchIndexColumns("CREATE TABLE t (a, b)"), []);
44
- });
45
- });
46
-
47
- describe("searchIndexShapeIsCurrent", () => {
48
- test("the shipped index matches itself", () => {
49
- assert.equal(searchIndexShapeIsCurrent(shipped, shipped), true);
50
- });
51
-
52
- // The upgrade this exists for: an instance carrying the two-column index the
53
- // previous build installed must be rebuilt, or every search raises.
54
- test("an index missing an indexed column is stale", () => {
55
- assert.equal(
56
- searchIndexShapeIsCurrent(installed(["subject", "sender"]), shipped),
57
- false,
58
- );
59
- });
60
-
61
- test("an index carrying every shipped column is current", () => {
62
- assert.equal(
63
- searchIndexShapeIsCurrent(
64
- installed(["subject", "sender", "body"]),
65
- shipped,
66
- ),
67
- true,
68
- );
69
- });
70
-
71
- // An extra column indexes text nothing reads. It costs space and matches
72
- // nothing wrong, so it is not a reason to drop and re-tokenize the table.
73
- test("an index carrying more than the shipped columns is current", () => {
74
- assert.equal(
75
- searchIndexShapeIsCurrent(
76
- installed(["subject", "sender", "body", "labels"]),
77
- shipped,
78
- ),
79
- true,
80
- );
81
- });
82
- });
@@ -1,56 +0,0 @@
1
- /**
2
- * Whether an installed FTS5 search index carries the columns the shipped one
3
- * declares.
4
- *
5
- * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against a table that already
6
- * exists, so a database installed by an earlier build keeps that build's
7
- * columns for good. A predicate naming a column that build never indexed does
8
- * not degrade — SQLite raises, and every search on that instance fails — so the
9
- * migrator compares the two shapes and rebuilds where they differ.
10
- *
11
- * Both sides are read from DDL rather than from a hand-kept list: the shipped
12
- * side is the committed `.sql`, the installed side is what `sqlite_master`
13
- * holds, and neither can drift from what is actually there.
14
- */
15
-
16
- /** The text between the parentheses of a `USING fts5(...)` clause. */
17
- const fts5Arguments = (ddl: string): string => {
18
- const clause = ddl.search(/using\s+fts5\s*\(/i);
19
- if (clause === -1) return "";
20
- const open = ddl.indexOf("(", clause);
21
- let depth = 0;
22
- for (let index = open; index < ddl.length; index++) {
23
- const char = ddl[index];
24
- if (char === "(") depth++;
25
- if (char !== ")") continue;
26
- depth--;
27
- if (depth === 0) return ddl.slice(open + 1, index);
28
- }
29
- return "";
30
- };
31
-
32
- /**
33
- * The indexed column names an FTS5 table declares. An argument carrying an `=`
34
- * is an option (`content=`, `tokenize=`), not a column, and anything quoted or
35
- * otherwise unusual is left out rather than guessed at — a name this cannot
36
- * read is a name the comparison must not claim is missing.
37
- */
38
- export const searchIndexColumns = (ddl: string): string[] =>
39
- fts5Arguments(ddl)
40
- .split(",")
41
- .map((argument) => argument.trim())
42
- .filter((argument) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(argument));
43
-
44
- /**
45
- * Whether `installed` indexes every column `shipped` does. A superset passes:
46
- * an extra column indexes text no predicate reads, which costs space and
47
- * matches nothing wrong.
48
- */
49
- export const searchIndexShapeIsCurrent = (
50
- installed: string,
51
- shipped: string,
52
- ): boolean => {
53
- const columns = new Set(searchIndexColumns(installed));
54
- const expected = searchIndexColumns(shipped);
55
- return expected.length > 0 && expected.every((column) => columns.has(column));
56
- };
@@ -1,199 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { eq } from "drizzle-orm";
4
- import type { Db } from "../db.js";
5
- import {
6
- type MessageDataSchema,
7
- messageDataSchema,
8
- outboxTable,
9
- } from "../schema/message-data.js";
10
- import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
11
- import { runInTransaction, serializeSqliteWrites } from "../tx.js";
12
-
13
- // The RFC 036 D3 hard requirement: every write on the shared better-sqlite3
14
- // connection routes through the wave-1 serialization, so a plain single
15
- // statement can never land inside another unit's open SAVEPOINT (the
16
- // uncommitted-read / rollback hazard named on PR #1310). The db handle repos
17
- // hold is `serializeSqliteWrites(db)` — unserialized access is impossible by
18
- // construction. The `outbox` table stands in for any write target here (few
19
- // required columns, already dialect-cast for the repos).
20
-
21
- const row = (id: string) => ({
22
- id,
23
- messageId: `msg-${id}`,
24
- event: "message.body_synced" as const,
25
- payload: { messageId: `msg-${id}` },
26
- createdAt: new Date(),
27
- });
28
-
29
- const nextTick = (): Promise<void> =>
30
- new Promise((resolve) => setImmediate(resolve));
31
-
32
- describe("serialized SQLite writes (RFC 036 D3)", () => {
33
- let raw: SqliteTestDb<MessageDataSchema>;
34
- let db: Db<MessageDataSchema>;
35
- let close: () => Promise<void>;
36
-
37
- before(async () => {
38
- ({ db: raw, close } =
39
- await createSqliteTestDb<MessageDataSchema>(messageDataSchema));
40
- db = serializeSqliteWrites(raw);
41
- });
42
-
43
- after(async () => {
44
- await close();
45
- });
46
-
47
- const byId = (id: string) =>
48
- raw.select().from(outboxTable).where(eq(outboxTable.id, id));
49
-
50
- test("a single-statement write does not interleave into an open transaction", async () => {
51
- const order: string[] = [];
52
- let release: () => void = () => {};
53
- const barrier = new Promise<void>((resolve) => {
54
- release = resolve;
55
- });
56
-
57
- // A top-level transaction: insert TX_ROW, park on a barrier (yielding the
58
- // event loop with the SAVEPOINT open), then throw so the whole unit rolls
59
- // back.
60
- const txDone = runInTransaction(db, async (tx) => {
61
- await tx.insert(outboxTable).values(row("tx-row"));
62
- order.push("tx-insert");
63
- await barrier;
64
- order.push("tx-throw");
65
- throw new Error("rollback");
66
- }).catch(() => {
67
- order.push("tx-rolledback");
68
- });
69
-
70
- // Let the transaction reach the barrier before the single write is issued.
71
- await nextTick();
72
- await nextTick();
73
-
74
- const singleWrite = db
75
- .insert(outboxTable)
76
- .values(row("single-row"))
77
- .then(() => {
78
- order.push("single-committed");
79
- });
80
-
81
- // While the transaction holds the write queue, the single write must not
82
- // have executed — it is queued behind the open unit, not interleaved into
83
- // its savepoint.
84
- await nextTick();
85
- await nextTick();
86
- assert.equal(
87
- order.includes("single-committed"),
88
- false,
89
- "single write ran while a top-level transaction was open",
90
- );
91
-
92
- release();
93
- await txDone;
94
- await singleWrite;
95
-
96
- assert.deepEqual(order, [
97
- "tx-insert",
98
- "tx-throw",
99
- "tx-rolledback",
100
- "single-committed",
101
- ]);
102
-
103
- // The transaction rolled back; the single write committed independently —
104
- // it was never part of the rolled-back savepoint.
105
- assert.equal(
106
- (await byId("tx-row")).length,
107
- 0,
108
- "transaction must roll back",
109
- );
110
- assert.equal(
111
- (await byId("single-row")).length,
112
- 1,
113
- "single write must commit, not be swallowed by the rolled-back transaction",
114
- );
115
- });
116
-
117
- test("concurrent single-statement writes all commit", async () => {
118
- const ids = Array.from({ length: 20 }, (_, i) => `w-${i}`);
119
- await Promise.all(ids.map((id) => db.insert(outboxTable).values(row(id))));
120
- for (const id of ids) {
121
- assert.equal((await byId(id)).length, 1, `${id} must be committed`);
122
- }
123
- });
124
-
125
- test("the explicit .run() executor is also serialized, not just await", async () => {
126
- // A write executed via `.run()` (not `await`) must still go through the
127
- // queue — the hardened wrapper intercepts every terminal executor.
128
- const builder = db
129
- .insert(outboxTable)
130
- .values(row("run-row")) as unknown as {
131
- run: () => Promise<unknown>;
132
- };
133
- await builder.run();
134
- assert.equal((await byId("run-row")).length, 1, "run() committed the row");
135
- });
136
-
137
- test("the .values() executor is serialized, not run inline", async () => {
138
- // `values` doubles as the insert chain method and (on a runnable with
139
- // `.returning()`) a terminal executor. The executor form must queue like
140
- // every other terminal — issued while a transaction is parked, it must
141
- // wait for the unit to finish instead of interleaving into its savepoint.
142
- const order: string[] = [];
143
- let release: () => void = () => {};
144
- const barrier = new Promise<void>((resolve) => {
145
- release = resolve;
146
- });
147
-
148
- const txDone = runInTransaction(db, async (tx) => {
149
- await tx.insert(outboxTable).values(row("values-tx-row"));
150
- order.push("tx-insert");
151
- await barrier;
152
- });
153
-
154
- await nextTick();
155
- await nextTick();
156
-
157
- const runnable = db
158
- .insert(outboxTable)
159
- .values(row("values-row"))
160
- .returning() as unknown as { values: () => Promise<unknown[]> };
161
- const executorWrite = runnable.values().then(() => {
162
- order.push("values-committed");
163
- });
164
-
165
- await nextTick();
166
- await nextTick();
167
- assert.equal(
168
- order.includes("values-committed"),
169
- false,
170
- ".values() executor ran while a top-level transaction was open",
171
- );
172
-
173
- release();
174
- await txDone;
175
- await executorWrite;
176
- assert.deepEqual(order, ["tx-insert", "values-committed"]);
177
- assert.equal((await byId("values-row")).length, 1);
178
- });
179
-
180
- test("writes inside a transaction still commit atomically", async () => {
181
- await runInTransaction(db, async (tx) => {
182
- await tx.insert(outboxTable).values(row("a"));
183
- await tx
184
- .update(outboxTable)
185
- .set({ event: "message.moved" })
186
- .where(eq(outboxTable.id, "a"));
187
- });
188
- const [committed] = await byId("a");
189
- assert.equal(committed?.event, "message.moved");
190
-
191
- await assert.rejects(() =>
192
- runInTransaction(db, async (tx) => {
193
- await tx.insert(outboxTable).values(row("b"));
194
- throw new Error("boom");
195
- }),
196
- );
197
- assert.equal((await byId("b")).length, 0, "transaction rollback drops b");
198
- });
199
- });
@@ -1,138 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { eq } from "drizzle-orm";
4
- import {
5
- envelopeId as deriveEnvelopeId,
6
- rootBodyPartId as deriveRootBodyPartId,
7
- } from "../id.js";
8
- import { addressTable } from "../schema/i4-address.js";
9
- import {
10
- type MessageDataSchema,
11
- messageDataSchema,
12
- messageTable,
13
- } from "../schema/message-data.js";
14
- import { threadMessageTable } from "../schema/thread-message.js";
15
- import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
16
- import { DrizzleUnitOfWork } from "./unit-of-work.js";
17
-
18
- // The unit-of-work runs its write set inside one SAVEPOINT on sqlite (RFC 036
19
- // D1) — and each repo's own `create` opens a nested SAVEPOINT, so this also
20
- // covers savepoint nesting. Commit persists the whole set; a throw rolls all of
21
- // it back, message and thread-message together.
22
-
23
- const SCHEMA = {
24
- ...messageDataSchema,
25
- threadMessage: threadMessageTable,
26
- address: addressTable,
27
- };
28
-
29
- describe("DrizzleUnitOfWork (sqlite)", () => {
30
- let db: SqliteTestDb<MessageDataSchema>;
31
- let close: () => Promise<void>;
32
- let uow: DrizzleUnitOfWork;
33
-
34
- before(async () => {
35
- ({ db, close } = await createSqliteTestDb<MessageDataSchema>(SCHEMA));
36
- uow = new DrizzleUnitOfWork(db);
37
- });
38
-
39
- after(async () => {
40
- await close();
41
- });
42
-
43
- const messageInput = (id: string) => ({
44
- messageId: id,
45
- mailboxId: "00000000-0000-0000-3333-000000000002",
46
- uid: 7,
47
- sequenceNumber: 1,
48
- rfc822Size: 512,
49
- internalDate: 1700000000000,
50
- envelopeId: deriveEnvelopeId(id),
51
- rootBodyPartId: deriveRootBodyPartId(id),
52
- status: "active" as const,
53
- syncStatus: "synced" as const,
54
- });
55
-
56
- const threadInput = (id: string) => ({
57
- accountConfigId: "acct-1",
58
- threadId: `thread-${id}`,
59
- messageId: id,
60
- mailboxId: "mbx-1",
61
- uid: 7,
62
- referenceOrder: 0,
63
- internalDate: 1700000000000,
64
- sentDate: 1700000000000,
65
- isRead: false,
66
- isDeleted: false,
67
- hasAttachment: false,
68
- hasStars: false,
69
- });
70
-
71
- test("commits message + thread-message together", async () => {
72
- const id = "00000000-0000-0000-3333-000000000010";
73
- await uow.transaction(async (repos) => {
74
- await repos.message.create(messageInput(id));
75
- await repos.threadMessage.create(threadInput(id));
76
- });
77
-
78
- const msg = await db
79
- .select()
80
- .from(messageTable)
81
- .where(eq(messageTable.messageId, id));
82
- const thread = await db
83
- .select()
84
- .from(threadMessageTable)
85
- .where(eq(threadMessageTable.messageId, id));
86
- assert.equal(msg.length, 1);
87
- assert.equal(thread.length, 1);
88
- });
89
-
90
- test("a throw rolls the whole set back", async () => {
91
- const id = "00000000-0000-0000-3333-000000000011";
92
- await assert.rejects(() =>
93
- uow.transaction(async (repos) => {
94
- await repos.message.create(messageInput(id));
95
- await repos.threadMessage.create(threadInput(id));
96
- throw new Error("boom");
97
- }),
98
- );
99
-
100
- const msg = await db
101
- .select()
102
- .from(messageTable)
103
- .where(eq(messageTable.messageId, id));
104
- const thread = await db
105
- .select()
106
- .from(threadMessageTable)
107
- .where(eq(threadMessageTable.messageId, id));
108
- assert.equal(msg.length, 0, "message insert must roll back");
109
- assert.equal(thread.length, 0, "thread-message insert must roll back");
110
- });
111
-
112
- test("concurrent transactions serialize without corrupting savepoints", async () => {
113
- const ids = Array.from(
114
- { length: 12 },
115
- (_, i) =>
116
- `00000000-0000-0000-3333-0000000001${i.toString().padStart(2, "0")}`,
117
- );
118
-
119
- // The failure this guards: shared-connection savepoint interleaving when
120
- // callers run under concurrency (message-sync's pMap). All must commit.
121
- await Promise.all(
122
- ids.map((id) =>
123
- uow.transaction(async (repos) => {
124
- await repos.message.create(messageInput(id));
125
- await repos.threadMessage.create(threadInput(id));
126
- }),
127
- ),
128
- );
129
-
130
- for (const id of ids) {
131
- const msg = await db
132
- .select()
133
- .from(messageTable)
134
- .where(eq(messageTable.messageId, id));
135
- assert.equal(msg.length, 1, `message ${id} must be committed`);
136
- }
137
- });
138
- });
@@ -1,109 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { eq } from "drizzle-orm";
4
- import {
5
- envelopeId as deriveEnvelopeId,
6
- rootBodyPartId as deriveRootBodyPartId,
7
- } from "../id.js";
8
- import {
9
- envelopeTable,
10
- messageTable,
11
- outboxTable,
12
- } from "../schema/message-data.js";
13
- import { createTestDb, type TestDb } from "./test-helpers.js";
14
- import { DrizzleUnitOfWork } from "./unit-of-work.js";
15
-
16
- describe("DrizzleUnitOfWork", () => {
17
- let db: TestDb;
18
- let stop: () => Promise<void>;
19
- let unitOfWork: DrizzleUnitOfWork;
20
-
21
- const MESSAGE_ID = "00000000-0000-0000-2222-000000000001";
22
- const MAILBOX_ID = "00000000-0000-0000-2222-000000000002";
23
- const NOW = 1700000000000;
24
-
25
- const writeEnvelopeMessageAndBody = async (
26
- repos: Parameters<Parameters<DrizzleUnitOfWork["transaction"]>[0]>[0],
27
- ) => {
28
- await repos.envelope.upsertEnvelope({
29
- envelopeId: deriveEnvelopeId(MESSAGE_ID),
30
- messageId: MESSAGE_ID,
31
- dateValue: NOW,
32
- dateRaw: "Tue, 14 Nov 2023 22:13:20 +0000",
33
- subject: "hello",
34
- messageIdValue: "<hello@example.com>",
35
- });
36
- await repos.message.upsertWithStatus({
37
- messageId: MESSAGE_ID,
38
- mailboxId: MAILBOX_ID,
39
- uid: 7,
40
- sequenceNumber: 1,
41
- rfc822Size: 1024,
42
- internalDate: NOW,
43
- envelopeId: deriveEnvelopeId(MESSAGE_ID),
44
- rootBodyPartId: deriveRootBodyPartId(MESSAGE_ID),
45
- });
46
- // Body-sync appends the transactional-outbox row; the create emits none.
47
- await repos.message.update(MESSAGE_ID, {
48
- bodyStorageKey: "body/hello.json",
49
- });
50
- };
51
-
52
- const rows = async () => {
53
- const envelopes = await db
54
- .select()
55
- .from(envelopeTable)
56
- .where(eq(envelopeTable.messageId, MESSAGE_ID));
57
- const messages = await db
58
- .select()
59
- .from(messageTable)
60
- .where(eq(messageTable.messageId, MESSAGE_ID));
61
- const outbox = await db
62
- .select()
63
- .from(outboxTable)
64
- .where(eq(outboxTable.messageId, MESSAGE_ID));
65
- return { envelopes, messages, outbox };
66
- };
67
-
68
- before(async () => {
69
- ({ db, stop } = await createTestDb());
70
- unitOfWork = new DrizzleUnitOfWork(db);
71
- });
72
-
73
- after(async () => {
74
- await stop();
75
- });
76
-
77
- test("a mid-save throw rolls back the whole write set, outbox included", async () => {
78
- await assert.rejects(
79
- () =>
80
- unitOfWork.transaction(async (repos) => {
81
- await writeEnvelopeMessageAndBody(repos);
82
- // A later write in the set fails after the envelope, message and its
83
- // transactional-outbox row have already been written.
84
- throw new Error("thread write failed");
85
- }),
86
- /thread write failed/,
87
- );
88
-
89
- const { envelopes, messages, outbox } = await rows();
90
- assert.equal(envelopes.length, 0, "envelope must be rolled back");
91
- assert.equal(messages.length, 0, "message must be rolled back");
92
- assert.equal(
93
- outbox.length,
94
- 0,
95
- "outbox row must be rolled back with the message",
96
- );
97
- });
98
-
99
- test("a successful transaction commits the data rows and the outbox row", async () => {
100
- await unitOfWork.transaction(writeEnvelopeMessageAndBody);
101
-
102
- const { envelopes, messages, outbox } = await rows();
103
- assert.equal(envelopes.length, 1);
104
- assert.equal(messages.length, 1);
105
- assert.equal(outbox.length, 1);
106
- assert.equal(outbox[0].event, "message.body_synced");
107
- assert.deepStrictEqual(outbox[0].payload, { messageId: MESSAGE_ID });
108
- });
109
- });
@@ -1,31 +0,0 @@
1
- import type { IUnitOfWork, UnitOfWorkRepositories } from "@remit/data-ports";
2
- import type { Db } from "../db.js";
3
- import type { MessageDataSchema } from "../schema/message-data.js";
4
- import { runInTransaction } from "../tx.js";
5
- import { DrizzleEnvelopeRepository } from "./envelope.js";
6
- import { AddressRepo } from "./i4-address.js";
7
- import { DrizzleMessageRepository } from "./message.js";
8
- import { DrizzleThreadMessageRepository } from "./thread-message.js";
9
-
10
- /**
11
- * Runs a write set inside a single transaction. The repositories handed
12
- * to the callback are bound to that transaction, so the data rows and the
13
- * transactional-outbox rows the message write appends commit atomically — a
14
- * throw anywhere rolls the whole set back, outbox included.
15
- */
16
- export class DrizzleUnitOfWork implements IUnitOfWork {
17
- constructor(private db: Db<MessageDataSchema>) {}
18
-
19
- transaction<T>(
20
- fn: (repos: UnitOfWorkRepositories) => Promise<T>,
21
- ): Promise<T> {
22
- return runInTransaction(this.db, (tx) =>
23
- fn({
24
- message: new DrizzleMessageRepository(tx),
25
- envelope: new DrizzleEnvelopeRepository(tx),
26
- address: new AddressRepo(tx),
27
- threadMessage: new DrizzleThreadMessageRepository(tx),
28
- }),
29
- );
30
- }
31
- }
@@ -1,46 +0,0 @@
1
- import { sql } from "drizzle-orm";
2
- import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
-
4
- /**
5
- * Transactional outbox. It has no TypeSpec entity — it is infrastructure for
6
- * the search-index worker (append a row per body change / move, drain by id,
7
- * mark `processed_at`). The partial index selects unprocessed rows for the
8
- * short-cadence poll (RFC 036 D2).
9
- *
10
- * Exported raw for committed-migration generation (schema-full-sqlite) as well
11
- * as for the repos and the drain logic.
12
- */
13
- /**
14
- * The event vocabulary a producer may append. Every kind here is drained by the
15
- * search-index relay (`DRAIN_EVENTS`); a kind nothing drains accumulates
16
- * undrained rows forever, which is what `message.created` did (reader#1063).
17
- * The column carries the union so a new producer cannot invent a kind without
18
- * adding it here, where the drain-coverage test picks it up.
19
- */
20
- export const OUTBOX_EVENTS = [
21
- "message.body_synced",
22
- "message.moved",
23
- "message.removed",
24
- ] as const;
25
-
26
- export type OutboxEvent = (typeof OUTBOX_EVENTS)[number];
27
-
28
- export const outboxTable = sqliteTable(
29
- "outbox",
30
- {
31
- id: text("id").primaryKey(),
32
- messageId: text("message_id").notNull(),
33
- event: text("event", { enum: OUTBOX_EVENTS }).notNull(),
34
- payload: text("payload", { mode: "json" }).notNull(),
35
- createdAt: integer("created_at", { mode: "timestamp_ms" })
36
- .$defaultFn(() => new Date())
37
- .notNull(),
38
- processedAt: integer("processed_at", { mode: "number" }),
39
- },
40
- (t) => [
41
- index("outbox_message_id_idx").on(t.messageId),
42
- index("outbox_unprocessed_idx")
43
- .on(t.createdAt)
44
- .where(sql`${t.processedAt} IS NULL`),
45
- ],
46
- );
@@ -1,13 +0,0 @@
1
- // Complete SQLite drizzle schema for committed-migration GENERATION only
2
- // (RFC 036 D5) — consumed by packages/migrate/drizzle.entities.sqlite.config.ts
3
- // and the drift guard (npm-scripts/check-vps-migrations.mjs), never at runtime.
4
- //
5
- // It pulls the generated entity package in wholesale, so a new TypeSpec entity
6
- // flows into the committed migration with nothing to hand-maintain. The only
7
- // addition is the `outbox` infra table, which has no entity.
8
- //
9
- // schema.ts stays the app/dev surface (single `*Table` alias per table, what
10
- // the repos and `pushSchema` need); this file exposes canonical names, so it
11
- // must not be fed to `pushSchema` alongside schema.ts (duplicate index names).
12
- export * from "@remit/drizzle-sqlite-schema";
13
- export { outboxTable } from "./schema/outbox.js";