@remit/drizzle-service 0.0.30 → 0.0.32

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.30",
3
+ "version": "0.0.32",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,507 @@
1
+ /**
2
+ * The behaviour `thread_message.category`'s repair (#321) must have, asserted
3
+ * against a real engine of each dialect from one place: the statement ships in
4
+ * two dialects and an assertion that only ran on one of them is how a dialect
5
+ * quietly stops being repaired.
6
+ *
7
+ * Every seed is raw SQL against the real column names, so it exercises the same
8
+ * statement text the migrator runs rather than a drizzle re-spelling of it.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { after, before, beforeEach, describe, test } from "node:test";
12
+ import {
13
+ type CategoryDivergenceReport,
14
+ checkThreadMessageCategory,
15
+ formatCheckReport,
16
+ formatRepairResult,
17
+ formatRepairSkipped,
18
+ type RepairSqlClient,
19
+ readResidual,
20
+ repairThreadMessageCategory,
21
+ } from "./thread-message-category.js";
22
+
23
+ export type RepairHarness = {
24
+ readonly client: RepairSqlClient;
25
+ readonly reset: () => Promise<void>;
26
+ readonly close: () => Promise<void>;
27
+ };
28
+
29
+ const literal = (value: string): string => `'${value.replace(/'/g, "''")}'`;
30
+
31
+ type MessageSeed = {
32
+ readonly messageId: string;
33
+ readonly category: string;
34
+ };
35
+
36
+ type ThreadMessageSeed = {
37
+ readonly threadMessageId: string;
38
+ readonly messageId: string;
39
+ readonly category: string;
40
+ readonly mailboxId?: string;
41
+ readonly updatedAt?: number;
42
+ };
43
+
44
+ const insertMessage = ({ messageId, category }: MessageSeed): string =>
45
+ `INSERT INTO message (
46
+ message_id, mailbox_id, uid, sequence_number, rfc822_size, internal_date,
47
+ envelope_id, root_body_part_id, category, created_at, updated_at
48
+ ) VALUES (
49
+ ${literal(messageId)}, 'mbx-inbox', 1, 1, 100, 0,
50
+ 'env-1', 'part-1', ${literal(category)}, 0, 0
51
+ )`;
52
+
53
+ const insertThreadMessage = ({
54
+ threadMessageId,
55
+ messageId,
56
+ category,
57
+ mailboxId = "mbx-inbox",
58
+ updatedAt = 1000,
59
+ }: ThreadMessageSeed): string =>
60
+ `INSERT INTO thread_message (
61
+ thread_message_id, thread_id, message_id, account_config_id, mailbox_id, uid,
62
+ reference_order, internal_date, sent_date, is_read, has_attachment, has_stars,
63
+ is_deleted, category, created_at, updated_at
64
+ ) VALUES (
65
+ ${literal(threadMessageId)}, 'thr-1', ${literal(messageId)}, 'acct-1',
66
+ ${literal(mailboxId)}, 1, 0, 0, 0, false, false, false, false,
67
+ ${literal(category)}, 0, ${updatedAt}
68
+ )`;
69
+
70
+ type ThreadMessageRow = {
71
+ readonly threadMessageId: string;
72
+ readonly category: string;
73
+ readonly updatedAt: number;
74
+ };
75
+
76
+ const readRows = async (
77
+ client: RepairSqlClient,
78
+ ): Promise<ThreadMessageRow[]> => {
79
+ const rows = await client.all(
80
+ "SELECT thread_message_id, category, updated_at FROM thread_message ORDER BY thread_message_id",
81
+ );
82
+ return rows.map((row) => {
83
+ if (typeof row !== "object" || row === null) {
84
+ throw new Error("expected a row object");
85
+ }
86
+ const record: Record<string, unknown> = { ...row };
87
+ return {
88
+ threadMessageId: String(record.thread_message_id),
89
+ category: String(record.category),
90
+ updatedAt: Number(record.updated_at),
91
+ };
92
+ });
93
+ };
94
+
95
+ const categoryOf = (
96
+ rows: readonly ThreadMessageRow[],
97
+ threadMessageId: string,
98
+ ): string => {
99
+ const row = rows.find((entry) => entry.threadMessageId === threadMessageId);
100
+ if (!row) {
101
+ throw new Error(`no thread_message row ${threadMessageId}`);
102
+ }
103
+ return row.category;
104
+ };
105
+
106
+ export const describeRepairContract = (
107
+ label: string,
108
+ open: () => Promise<RepairHarness>,
109
+ ): void => {
110
+ describe(`thread_message.category repair — ${label}`, () => {
111
+ let harness: RepairHarness;
112
+
113
+ before(async () => {
114
+ harness = await open();
115
+ });
116
+
117
+ after(async () => {
118
+ await harness.close();
119
+ });
120
+
121
+ beforeEach(async () => {
122
+ await harness.reset();
123
+ });
124
+
125
+ const seed = async (
126
+ messages: readonly MessageSeed[],
127
+ threadMessages: readonly ThreadMessageSeed[],
128
+ ): Promise<void> => {
129
+ for (const message of messages) {
130
+ await harness.client.run(insertMessage(message));
131
+ }
132
+ for (const threadMessage of threadMessages) {
133
+ await harness.client.run(insertThreadMessage(threadMessage));
134
+ }
135
+ };
136
+
137
+ const check = (): Promise<CategoryDivergenceReport> =>
138
+ checkThreadMessageCategory(harness.client);
139
+
140
+ test("repairs a stale row and does not touch a matching one", async () => {
141
+ await seed(
142
+ [
143
+ { messageId: "msg-stale", category: "newsletter" },
144
+ { messageId: "msg-agrees", category: "social" },
145
+ ],
146
+ [
147
+ {
148
+ threadMessageId: "tm-stale",
149
+ messageId: "msg-stale",
150
+ category: "uncategorized",
151
+ },
152
+ {
153
+ threadMessageId: "tm-agrees",
154
+ messageId: "msg-agrees",
155
+ category: "social",
156
+ updatedAt: 4242,
157
+ },
158
+ ],
159
+ );
160
+
161
+ const before = await check();
162
+ assert.equal(before.rows, 2);
163
+ assert.equal(before.divergent, 1);
164
+ assert.equal(before.repairable, 1);
165
+ assert.equal(before.behind, 1);
166
+ assert.equal(before.crossed, 0);
167
+
168
+ const result = await repairThreadMessageCategory(harness.client);
169
+ assert.equal(result.rowsWritten, 1);
170
+
171
+ const rows = await readRows(harness.client);
172
+ assert.equal(categoryOf(rows, "tm-stale"), "newsletter");
173
+ assert.equal(categoryOf(rows, "tm-agrees"), "social");
174
+
175
+ const untouched = rows.find(
176
+ (row) => row.threadMessageId === "tm-agrees",
177
+ )?.updatedAt;
178
+ assert.equal(untouched, 4242);
179
+
180
+ const after = await check();
181
+ assert.equal(after.divergent, 0);
182
+ assert.equal(after.repairable, 0);
183
+ });
184
+
185
+ test("repairs a row classified differently from its message", async () => {
186
+ await seed(
187
+ [{ messageId: "msg-crossed", category: "marketing" }],
188
+ [
189
+ {
190
+ threadMessageId: "tm-crossed",
191
+ messageId: "msg-crossed",
192
+ category: "personal",
193
+ },
194
+ ],
195
+ );
196
+
197
+ const before = await check();
198
+ assert.equal(before.crossed, 1);
199
+ assert.equal(before.behind, 0);
200
+ assert.equal(before.repairable, 1);
201
+
202
+ await repairThreadMessageCategory(harness.client);
203
+ const rows = await readRows(harness.client);
204
+ assert.equal(categoryOf(rows, "tm-crossed"), "marketing");
205
+ });
206
+
207
+ test("leaves a row pending when its message is pending", async () => {
208
+ await seed(
209
+ [{ messageId: "msg-pending", category: "uncategorized" }],
210
+ [
211
+ {
212
+ threadMessageId: "tm-pending",
213
+ messageId: "msg-pending",
214
+ category: "uncategorized",
215
+ },
216
+ ],
217
+ );
218
+
219
+ const before = await check();
220
+ assert.equal(before.notYetClassified, 1);
221
+ assert.equal(before.divergent, 0);
222
+
223
+ const result = await repairThreadMessageCategory(harness.client);
224
+ assert.equal(result.rowsWritten, 0);
225
+
226
+ const rows = await readRows(harness.client);
227
+ assert.equal(categoryOf(rows, "tm-pending"), "uncategorized");
228
+ assert.equal((await check()).notYetClassified, 1);
229
+ });
230
+
231
+ // `uncategorized` is a declared pending state, not absence (#45). After #326
232
+ // body-sync writes the row before the message, so a row classified against a
233
+ // pending message is a classification in flight — pushing it back would undo
234
+ // a correct classification and serve Unclassified for classified mail. This
235
+ // is #326's closing question, answered: the row is left alone, not converged.
236
+ test("never copies pending over a classified row", async () => {
237
+ await seed(
238
+ [{ messageId: "msg-ahead", category: "uncategorized" }],
239
+ [
240
+ {
241
+ threadMessageId: "tm-ahead",
242
+ messageId: "msg-ahead",
243
+ category: "personal",
244
+ },
245
+ ],
246
+ );
247
+
248
+ const before = await check();
249
+ assert.equal(before.divergent, 1);
250
+ assert.equal(before.ahead, 1);
251
+ assert.equal(before.repairable, 0);
252
+
253
+ const result = await repairThreadMessageCategory(harness.client);
254
+ assert.equal(result.rowsWritten, 0);
255
+
256
+ const rows = await readRows(harness.client);
257
+ assert.equal(categoryOf(rows, "tm-ahead"), "personal");
258
+ assert.match(
259
+ formatCheckReport(await check()).join("\n"),
260
+ /ahead: 1 row .*Expected non-zero on a live instance mid-sync/,
261
+ );
262
+ });
263
+
264
+ test("leaves a row with no message row alone and counts it", async () => {
265
+ await seed(
266
+ [],
267
+ [
268
+ {
269
+ threadMessageId: "tm-orphan",
270
+ messageId: "msg-absent",
271
+ category: "personal",
272
+ },
273
+ ],
274
+ );
275
+
276
+ const before = await check();
277
+ assert.equal(before.rows, 1);
278
+ assert.equal(before.rowsWithMessage, 0);
279
+ assert.equal(before.orphanRows, 1);
280
+
281
+ const result = await repairThreadMessageCategory(harness.client);
282
+ assert.equal(result.rowsWritten, 0);
283
+ assert.equal(
284
+ categoryOf(await readRows(harness.client), "tm-orphan"),
285
+ "personal",
286
+ );
287
+ });
288
+
289
+ test("repairs every row of a message held in two mailboxes", async () => {
290
+ await seed(
291
+ [{ messageId: "msg-fanned", category: "transactional" }],
292
+ [
293
+ {
294
+ threadMessageId: "tm-inbox",
295
+ messageId: "msg-fanned",
296
+ category: "uncategorized",
297
+ },
298
+ {
299
+ threadMessageId: "tm-archive",
300
+ messageId: "msg-fanned",
301
+ mailboxId: "mbx-archive",
302
+ category: "uncategorized",
303
+ },
304
+ ],
305
+ );
306
+
307
+ const before = await check();
308
+ assert.equal(before.fanOutMessages, 1);
309
+ assert.equal(before.fanOutRows, 2);
310
+ assert.deepEqual(before.divergentByMailbox, [
311
+ { mailboxId: "mbx-archive", rows: 1 },
312
+ { mailboxId: "mbx-inbox", rows: 1 },
313
+ ]);
314
+
315
+ const result = await repairThreadMessageCategory(harness.client);
316
+ assert.equal(result.rowsWritten, 2);
317
+
318
+ const rows = await readRows(harness.client);
319
+ assert.equal(categoryOf(rows, "tm-inbox"), "transactional");
320
+ assert.equal(categoryOf(rows, "tm-archive"), "transactional");
321
+ assert.equal((await check()).divergent, 0);
322
+ });
323
+
324
+ test("is a no-op on the second run", async () => {
325
+ await seed(
326
+ [{ messageId: "msg-twice", category: "automated" }],
327
+ [
328
+ {
329
+ threadMessageId: "tm-twice",
330
+ messageId: "msg-twice",
331
+ category: "uncategorized",
332
+ },
333
+ ],
334
+ );
335
+
336
+ assert.equal(
337
+ (await repairThreadMessageCategory(harness.client)).rowsWritten,
338
+ 1,
339
+ );
340
+ const afterFirst = await readRows(harness.client);
341
+ assert.equal(
342
+ (await repairThreadMessageCategory(harness.client)).rowsWritten,
343
+ 0,
344
+ );
345
+ assert.deepEqual(await readRows(harness.client), afterFirst);
346
+ });
347
+
348
+ // The guard's mechanics: a row whose stamp is beyond the statement's clock
349
+ // fails the WHERE clause and is left alone. A row stamped that far ahead is
350
+ // not a concurrent writer — it is a clock that jumped or a restored backup —
351
+ // and the residual has to say so, because that row fails the guard on every
352
+ // run and no later start will fix it.
353
+ test("skips a row stamped beyond the statement's clock and says why", async () => {
354
+ await seed(
355
+ [
356
+ { messageId: "msg-ahead-clock", category: "newsletter" },
357
+ { messageId: "msg-quiet", category: "newsletter" },
358
+ ],
359
+ [
360
+ {
361
+ threadMessageId: "tm-ahead-clock",
362
+ messageId: "msg-ahead-clock",
363
+ category: "uncategorized",
364
+ updatedAt: Date.now() + 600_000,
365
+ },
366
+ {
367
+ threadMessageId: "tm-quiet",
368
+ messageId: "msg-quiet",
369
+ category: "uncategorized",
370
+ },
371
+ ],
372
+ );
373
+
374
+ const result = await repairThreadMessageCategory(harness.client);
375
+ assert.equal(result.rowsWritten, 1);
376
+
377
+ const rows = await readRows(harness.client);
378
+ assert.equal(categoryOf(rows, "tm-ahead-clock"), "uncategorized");
379
+ assert.equal(categoryOf(rows, "tm-quiet"), "newsletter");
380
+
381
+ const residual = await readResidual(harness.client);
382
+ assert.deepEqual(residual, { repairable: 1, aheadOfClock: 1 });
383
+
384
+ const reported = formatRepairResult(result, residual).join("\n");
385
+ assert.match(reported, /ahead of the database clock/);
386
+ assert.match(reported, /every run, not just this one/);
387
+ assert.ok(
388
+ !reported.includes("skipped because a writer touched them"),
389
+ "a clock-ahead row must not be reported as a concurrent writer",
390
+ );
391
+ });
392
+
393
+ test("names the concurrent writer and the update cadence when a run leaves a residual", () => {
394
+ const reported = formatRepairResult(
395
+ { rowsWritten: 4, elapsedMs: 7 },
396
+ { repairable: 2, aheadOfClock: 0 },
397
+ ).join("\n");
398
+ assert.match(reported, /2 rows skipped because a writer touched them/);
399
+ assert.match(reported, /the next 'remit update'/);
400
+ assert.ok(!reported.includes("ahead of the database clock"));
401
+ });
402
+
403
+ test("a clean run reports no warning at all", () => {
404
+ assert.deepEqual(
405
+ formatRepairResult(
406
+ { rowsWritten: 3, elapsedMs: 2 },
407
+ { repairable: 0, aheadOfClock: 0 },
408
+ ),
409
+ [
410
+ "repair wrote 3 rows in 2ms",
411
+ "residual repairable divergence: 0 (expected zero)",
412
+ ],
413
+ );
414
+ });
415
+
416
+ test("a skipped repair says no write lock was taken", async () => {
417
+ await seed(
418
+ [{ messageId: "msg-clean", category: "personal" }],
419
+ [
420
+ {
421
+ threadMessageId: "tm-clean",
422
+ messageId: "msg-clean",
423
+ category: "personal",
424
+ },
425
+ ],
426
+ );
427
+
428
+ const report = await check();
429
+ assert.equal(report.repairable, 0);
430
+ assert.match(
431
+ formatRepairSkipped(report).join("\n"),
432
+ /repair skipped: nothing repairable .*No write, so no write lock is taken\./,
433
+ );
434
+ });
435
+
436
+ test("--check writes nothing", async () => {
437
+ await seed(
438
+ [{ messageId: "msg-readonly", category: "personal" }],
439
+ [
440
+ {
441
+ threadMessageId: "tm-readonly",
442
+ messageId: "msg-readonly",
443
+ category: "uncategorized",
444
+ updatedAt: 7777,
445
+ },
446
+ ],
447
+ );
448
+
449
+ const rowsBefore = await readRows(harness.client);
450
+ await check();
451
+ assert.deepEqual(await readRows(harness.client), rowsBefore);
452
+ });
453
+
454
+ test("the report names a cause and its expected result for every figure", async () => {
455
+ await seed(
456
+ [{ messageId: "msg-report", category: "social" }],
457
+ [
458
+ {
459
+ threadMessageId: "tm-report",
460
+ messageId: "msg-report",
461
+ category: "uncategorized",
462
+ },
463
+ ],
464
+ );
465
+
466
+ const lines = formatCheckReport(await check());
467
+ const text = lines.join("\n");
468
+
469
+ for (const cause of [
470
+ "behind:",
471
+ "crossed:",
472
+ "ahead:",
473
+ "fan-out:",
474
+ "orphans:",
475
+ "not-yet-classified:",
476
+ "divergent per mailbox:",
477
+ "category tally:",
478
+ ]) {
479
+ assert.ok(text.includes(cause), `missing cause: ${cause}`);
480
+ }
481
+ assert.equal(
482
+ lines.filter((line) => /Expected (zero|non-zero)/.test(line)).length,
483
+ 6,
484
+ );
485
+ assert.match(text, /category tally: uncategorized=1/);
486
+ });
487
+
488
+ // The causes are the deliverable, so each one names something that can
489
+ // actually happen on the deployment this repair ships to. A cause an
490
+ // operator can rule out by inspection reads as a broken figure.
491
+ test("no figure cites a cause that cannot occur on this deployment", async () => {
492
+ const text = formatCheckReport(await check()).join("\n");
493
+ for (const stale of [
494
+ "2026-07-08",
495
+ "drizzle push",
496
+ "Postgres instance whose column was added",
497
+ ]) {
498
+ assert.ok(
499
+ !text.includes(stale),
500
+ `the report still cites ${stale}, which no SQLite instance can have experienced`,
501
+ );
502
+ }
503
+ assert.match(text, /behind: .*write-path defects #326 fixes end here/);
504
+ assert.match(text, /crossed: .*no write path leaves a row here/);
505
+ });
506
+ });
507
+ };
@@ -0,0 +1,101 @@
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 { messageTable } from "../schema/message-data.js";
8
+ import { threadMessageTable } from "../schema/thread-message.js";
9
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
10
+ import {
11
+ checkThreadMessageCategory,
12
+ type RepairSqlClient,
13
+ repairStatement,
14
+ } from "./thread-message-category.js";
15
+ import { describeRepairContract } from "./thread-message-category-contract.js";
16
+
17
+ const sqliteClient = (sqlite: Database.Database): RepairSqlClient => ({
18
+ dialect: "sqlite",
19
+ all: async (sql) => sqlite.prepare(sql).all(),
20
+ run: async (sql) => sqlite.prepare(sql).run().changes,
21
+ });
22
+
23
+ // The repair against a real better-sqlite3 database — the engine every self-host
24
+ // instance runs, and the one whose `unixepoch()` and single-writer serialization
25
+ // the statement leans on.
26
+ describeRepairContract("sqlite", async () => {
27
+ const { sqlite, close } = await createSqliteTestDb({
28
+ message: messageTable,
29
+ threadMessage: threadMessageTable,
30
+ });
31
+
32
+ return {
33
+ client: sqliteClient(sqlite),
34
+ reset: async () => {
35
+ sqlite.exec("DELETE FROM thread_message");
36
+ sqlite.exec("DELETE FROM message");
37
+ },
38
+ close,
39
+ };
40
+ });
41
+
42
+ // Why the repair is skipped when the check found nothing: SQLite takes its
43
+ // exclusive write lock when the UPDATE begins, before it can know the WHERE
44
+ // matches nothing. Without the skip, every boot of a healthy instance would
45
+ // contend for that lock, and a contended boot fails the whole migration — which
46
+ // holds all six gated services down.
47
+ describe("thread_message.category repair — SQLite's write lock", () => {
48
+ let dir: string;
49
+ let file: string;
50
+ let reader: Database.Database;
51
+ let writer: Database.Database;
52
+
53
+ before(async () => {
54
+ dir = mkdtempSync(join(tmpdir(), "remit-repair-lock-"));
55
+ file = join(dir, "remit.db");
56
+ const { sqlite, close } = await createSqliteTestDb(
57
+ { message: messageTable, threadMessage: threadMessageTable },
58
+ { filename: file },
59
+ );
60
+ sqlite.pragma("journal_mode = WAL");
61
+ await close();
62
+
63
+ // 200 ms rather than the migrator's busy_timeout of 5000: the point is that
64
+ // the lock is wanted at all, and waiting five seconds to prove it is waste.
65
+ reader = new Database(file, { timeout: 200 });
66
+ reader.pragma("journal_mode = WAL");
67
+ writer = new Database(file);
68
+ });
69
+
70
+ after(() => {
71
+ reader.close();
72
+ writer.close();
73
+ rmSync(dir, { recursive: true, force: true });
74
+ });
75
+
76
+ test("the check reads while another connection holds the write lock", async () => {
77
+ writer.exec("BEGIN IMMEDIATE");
78
+ try {
79
+ const report = await checkThreadMessageCategory(sqliteClient(reader));
80
+ assert.equal(report.rows, 0);
81
+ assert.equal(report.repairable, 0);
82
+ } finally {
83
+ writer.exec("ROLLBACK");
84
+ }
85
+ });
86
+
87
+ test("the statement still wants the lock with nothing to repair", async () => {
88
+ writer.exec("BEGIN IMMEDIATE");
89
+ try {
90
+ assert.throws(
91
+ () => reader.prepare(repairStatement("sqlite")).run(),
92
+ (error: unknown) =>
93
+ error instanceof Error &&
94
+ "code" in error &&
95
+ error.code === "SQLITE_BUSY",
96
+ );
97
+ } finally {
98
+ writer.exec("ROLLBACK");
99
+ }
100
+ });
101
+ });