@remit/drizzle-service 0.0.30 → 0.0.31

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.31",
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
+ });
@@ -0,0 +1,229 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { createTestDb } from "../test-db.js";
4
+ import {
5
+ type RepairSqlClient,
6
+ repairStatement,
7
+ repairThreadMessageCategory,
8
+ } from "./thread-message-category.js";
9
+ import { describeRepairContract } from "./thread-message-category-contract.js";
10
+
11
+ const { pool, close } = await createTestDb();
12
+
13
+ after(async () => {
14
+ await close();
15
+ });
16
+
17
+ const client: RepairSqlClient = {
18
+ dialect: "postgres",
19
+ all: async (sql) => (await pool.query(sql)).rows,
20
+ run: async (sql) => (await pool.query(sql)).rowCount ?? 0,
21
+ };
22
+
23
+ const truncate = async (): Promise<void> => {
24
+ await pool.query("DELETE FROM thread_message");
25
+ await pool.query("DELETE FROM message");
26
+ };
27
+
28
+ describeRepairContract("postgres", async () => ({
29
+ client,
30
+ reset: truncate,
31
+ close: async () => undefined,
32
+ }));
33
+
34
+ // Postgres is where a mid-sync run can go wrong, and the two interleavings have
35
+ // opposite outcomes — so each is driven with two real connections rather than
36
+ // argued about. Under READ COMMITTED an UPDATE that meets a row a concurrent
37
+ // transaction just changed re-evaluates its WHERE clause against the new version
38
+ // of that row, but keeps reading other tables at its original snapshot.
39
+ //
40
+ // Both cases need a *re*-classification to be observable at all: `message` moving
41
+ // from one decided category to another. `backfillClassification` cannot do that
42
+ // (it returns early once the category is decided), but the primary path is
43
+ // re-enterable — `if (message.bodyStorageKey && !force)`, where `force` is the
44
+ // read-miss re-arm cue — and a forced re-fetch decides differently across a
45
+ // release that changed the classifier. So the uncovered case below is
46
+ // effectively unreachable rather than impossible. The seeds are synthetic
47
+ // because reproducing it otherwise would mean changing the classifier.
48
+ describe("thread_message.category repair — a writer racing the statement", () => {
49
+ const insertMessage = async (
50
+ messageId: string,
51
+ category: string,
52
+ ): Promise<void> => {
53
+ await pool.query(
54
+ `INSERT INTO message (
55
+ message_id, mailbox_id, uid, sequence_number, rfc822_size, internal_date,
56
+ envelope_id, root_body_part_id, category, created_at, updated_at
57
+ ) VALUES ($1, 'mbx-inbox', 1, 1, 100, 0, 'env-1', 'part-1', $2, 0, 0)`,
58
+ [messageId, category],
59
+ );
60
+ };
61
+
62
+ const insertRow = async (
63
+ messageId: string,
64
+ category: string,
65
+ ): Promise<void> => {
66
+ await pool.query(
67
+ `INSERT INTO thread_message (
68
+ thread_message_id, thread_id, message_id, account_config_id, mailbox_id,
69
+ uid, reference_order, internal_date, sent_date, is_read, has_attachment,
70
+ has_stars, is_deleted, category, created_at, updated_at
71
+ ) VALUES ($1, 'thr-1', $1, 'acct-1', 'mbx-inbox', 1, 0, 0, 0,
72
+ false, false, false, false, $2, 0, 1000)`,
73
+ [messageId, category],
74
+ );
75
+ };
76
+
77
+ // A body-sync denormalize, faithful to the shipped order (#326): the row
78
+ // first, the message second, both stamped with the wall clock at write time.
79
+ const reclassify = async (
80
+ connection: {
81
+ query: (sql: string, values?: unknown[]) => Promise<unknown>;
82
+ },
83
+ messageId: string,
84
+ category: string,
85
+ ): Promise<void> => {
86
+ await connection.query(
87
+ "UPDATE thread_message SET category = $2, updated_at = $3 WHERE thread_message_id = $1",
88
+ [messageId, category, Date.now()],
89
+ );
90
+ await connection.query(
91
+ "UPDATE message SET category = $2, updated_at = $3 WHERE message_id = $1",
92
+ [messageId, category, Date.now()],
93
+ );
94
+ };
95
+
96
+ const categories = async (): Promise<Record<string, string>> => {
97
+ const { rows } = await pool.query<{
98
+ thread_message_id: string;
99
+ category: string;
100
+ }>("SELECT thread_message_id, category FROM thread_message");
101
+ return Object.fromEntries(
102
+ rows.map((row) => [row.thread_message_id, row.category]),
103
+ );
104
+ };
105
+
106
+ before(truncate);
107
+
108
+ // The case the guard covers: the writer's transaction begins after the
109
+ // statement, so its stamp is beyond the statement's clock and the re-check
110
+ // against the committed row excludes it. The repair is held on an unrelated
111
+ // row lock for the duration, which is what puts the writer inside the
112
+ // statement's window without touching its stamp.
113
+ test("a writer that begins after the statement keeps its value", async () => {
114
+ await truncate();
115
+ await insertMessage("aaa-blocker", "newsletter");
116
+ await insertRow("aaa-blocker", "uncategorized");
117
+ await insertMessage("bbb-raced", "newsletter");
118
+ await insertRow("bbb-raced", "uncategorized");
119
+
120
+ const holder = await pool.connect();
121
+ const writer = await pool.connect();
122
+ try {
123
+ await holder.query("BEGIN");
124
+ await holder.query(
125
+ "UPDATE thread_message SET updated_at = 1001 WHERE thread_message_id = 'aaa-blocker'",
126
+ );
127
+
128
+ const repairing = repairThreadMessageCategory(client);
129
+ // The repair reaches the blocker row — inserted first, so first in the
130
+ // sequential scan — and waits there. The sleep is on the holder's own
131
+ // connection, so nothing polls.
132
+ await holder.query("SELECT pg_sleep(0.5)");
133
+
134
+ await writer.query("BEGIN");
135
+ await reclassify(writer, "bbb-raced", "marketing");
136
+ await writer.query("COMMIT");
137
+
138
+ await holder.query("COMMIT");
139
+
140
+ // Only the blocker. A 2 here means the repair finished before the writer
141
+ // began, so the interleaving this test is named for did not happen.
142
+ assert.equal((await repairing).rowsWritten, 1);
143
+ } finally {
144
+ holder.release();
145
+ writer.release();
146
+ }
147
+
148
+ assert.deepEqual(await categories(), {
149
+ "aaa-blocker": "newsletter",
150
+ "bbb-raced": "marketing",
151
+ });
152
+ });
153
+
154
+ // The live mid-sync case, and it is safe: a writer whose transaction began
155
+ // before the statement, doing what body-sync actually does — filling in the
156
+ // copy of a category the message already holds. The repair blocks on the row,
157
+ // re-checks against the committed version, finds it now agrees, and writes
158
+ // nothing. This is the interleaving D16's safety argument rests on.
159
+ test("a concurrent denormalize of the same row leaves it correct", async () => {
160
+ await truncate();
161
+ await insertMessage("ddd-catchup", "newsletter");
162
+ await insertRow("ddd-catchup", "uncategorized");
163
+
164
+ const writer = await pool.connect();
165
+ try {
166
+ await writer.query("BEGIN");
167
+ await writer.query(
168
+ "UPDATE thread_message SET category = 'newsletter', updated_at = $1 WHERE thread_message_id = 'ddd-catchup'",
169
+ [Date.now()],
170
+ );
171
+
172
+ const repairing = repairThreadMessageCategory(client);
173
+ await writer.query("SELECT pg_sleep(0.5)");
174
+ await writer.query("COMMIT");
175
+
176
+ assert.equal((await repairing).rowsWritten, 0);
177
+ } finally {
178
+ writer.release();
179
+ }
180
+
181
+ assert.deepEqual(await categories(), { "ddd-catchup": "newsletter" });
182
+ });
183
+
184
+ // The known limit, pinned so it cannot silently widen. Reaching it needs four
185
+ // things at once: an unrepaired `behind` row, a writer whose transaction began
186
+ // before the statement, that writer's row write committing before the statement
187
+ // while its message write commits after, and that writer moving
188
+ // message.category from one decided value to a different one. Only a forced
189
+ // re-fetch across a classifier change does the fourth, which makes this
190
+ // effectively unreachable rather than impossible; the seed is synthetic and the
191
+ // assertion records the loss rather than endorsing it. If this test ever fails,
192
+ // the guard got stronger and D16 and the module header should say so.
193
+ test("a writer that began before the statement is outside the guard", async () => {
194
+ await truncate();
195
+ await insertMessage("ccc-reclass", "newsletter");
196
+ await insertRow("ccc-reclass", "uncategorized");
197
+
198
+ const writer = await pool.connect();
199
+ try {
200
+ await writer.query("BEGIN");
201
+ await reclassify(writer, "ccc-reclass", "marketing");
202
+
203
+ const repairing = repairThreadMessageCategory(client);
204
+ await writer.query("SELECT pg_sleep(0.5)");
205
+ await writer.query("COMMIT");
206
+
207
+ assert.equal((await repairing).rowsWritten, 1);
208
+ } finally {
209
+ writer.release();
210
+ }
211
+
212
+ assert.deepEqual(await categories(), { "ccc-reclass": "newsletter" });
213
+ const { rows } = await pool.query<{ category: string }>(
214
+ "SELECT category FROM message WHERE message_id = 'ccc-reclass'",
215
+ );
216
+ assert.deepEqual(rows, [{ category: "marketing" }]);
217
+ });
218
+
219
+ test("only the clock expression differs between the dialects", () => {
220
+ const postgres = repairStatement("postgres");
221
+ const sqlite = repairStatement("sqlite");
222
+ assert.ok(postgres.includes("EXTRACT(EPOCH FROM now())"));
223
+ assert.ok(sqlite.includes("unixepoch('subsec')"));
224
+ assert.equal(
225
+ postgres.replace(/CAST\(EXTRACT.*$/, ""),
226
+ sqlite.replace(/CAST\(unixepoch.*$/, ""),
227
+ );
228
+ });
229
+ });
@@ -0,0 +1,426 @@
1
+ /**
2
+ * The repair for `thread_message.category` (#321, D16 and D17 of
3
+ * `docs/design/mail-list-server-query.md`).
4
+ *
5
+ * `thread_message.category` is a denormalized copy of `message.category`. The
6
+ * read path never used it, so nothing ever depended on its write path being
7
+ * complete, and it has drifted. #304 makes it the SQL predicate behind the
8
+ * inbox category filter, so it has to be correct on every existing instance
9
+ * before that read path goes live — otherwise the filter under-returns mail
10
+ * with nothing masking it.
11
+ *
12
+ * One set-based statement per dialect. The value it copies is reachable through
13
+ * `message`'s primary key, so the write is single-valued by construction and
14
+ * there is no checkpointing, batching or resumability here: an interrupted run
15
+ * leaves a consistent table and the next start finishes the job.
16
+ *
17
+ * The statement runs only when the check found something to repair. SQLite takes
18
+ * its exclusive write lock when an UPDATE begins, before it can know the WHERE
19
+ * matches nothing, so an unconditional statement would take that lock on every
20
+ * boot forever — and a lock it cannot acquire within the migrator's
21
+ * `busy_timeout` fails the migration and holds every gated service down. The
22
+ * steady state after the first run is zero, so the skip is the normal path.
23
+ *
24
+ * Two things the statement will not do, both because the repair must never
25
+ * leave a row worse than it found it:
26
+ *
27
+ * - It never copies `uncategorized` over a classified row. `message` is the
28
+ * authority, but a pending message holds nothing to copy, and after #326
29
+ * body-sync writes the row before the message, so a classification in flight
30
+ * is legitimately `ahead` of its message for a moment. Pushing it back would
31
+ * undo a correct classification and, on the read path #328 builds, serve
32
+ * `Unclassified` for mail that is already classified. Counted as `ahead`.
33
+ * - It never overwrites a row that was written after the statement began. The
34
+ * migrate one-shot normally runs with every app service stopped, but
35
+ * `docker compose up -d` can restart it while workers still run, so the
36
+ * quiet window is not assumed. On SQLite writers are serialized, so a
37
+ * concurrent body-sync write lands wholly before or wholly after and both
38
+ * orders converge on the same value. On Postgres READ COMMITTED an UPDATE
39
+ * re-evaluates its WHERE clause against the version a concurrent
40
+ * transaction just committed, but still reads other tables at its original
41
+ * snapshot — so without the `updated_at` guard it could write a
42
+ * pre-classification value over the fresh one. The guard makes that row fail
43
+ * the re-check and the writer's value stands.
44
+ *
45
+ * The guard covers a writer whose transaction begins after the statement. A
46
+ * writer that began before it and commits during it carries an older stamp and
47
+ * passes, which would revert its write. Four things have to hold at once for
48
+ * that to lose anything:
49
+ *
50
+ * 1. a row the statement's WHERE matches, so an unrepaired `behind` row;
51
+ * 2. a writer overlapping the statement;
52
+ * 3. that writer's row write committing before the statement began while its
53
+ * message write commits after it — the two are separate commits;
54
+ * 4. the writer moving `message.category` from one decided value to a
55
+ * *different* decided value. A first classification cannot lose anything:
56
+ * `message.category` is still `uncategorized`, so the pending exclusion
57
+ * above refuses the row outright.
58
+ *
59
+ * Only the fourth is interesting, and it is not impossible. It is unreachable
60
+ * through `backfillClassification`, which returns early once the category is
61
+ * decided. It is reachable through the primary path, which is re-enterable: the
62
+ * skip guard is `if (message.bodyStorageKey && !force)` (`body-sync.ts`), and
63
+ * `force` is a live event flag — the read-miss re-arm cue, resolved in the
64
+ * imap-worker's `sync-message-body.ts`. A forced re-fetch re-runs
65
+ * `classifyByHeaders` over the same bytes, so it normally rewrites the value it
66
+ * already held; it decides *differently* only across a release that changed the
67
+ * classifier, which #62 was.
68
+ *
69
+ * So: effectively unreachable, needing a forced re-fetch of a message whose
70
+ * stored classification predates a classifier change, overlapping this
71
+ * statement. Not impossible, and the `crossed` figure is what reports it.
72
+ *
73
+ * The statement does not touch `updated_at`. That column means "when the app
74
+ * last wrote this row", which is what the guard above reads, and a repair that
75
+ * bumped it would both lie about the row and defeat its own guard.
76
+ */
77
+
78
+ export type RepairDialect = "sqlite" | "postgres";
79
+
80
+ /**
81
+ * The smallest surface the repair needs, so this module imports nothing and can
82
+ * be driven by the migrator's `better-sqlite3` handle, its `pg.Pool`, or a test
83
+ * harness of either dialect.
84
+ */
85
+ export interface RepairSqlClient {
86
+ readonly dialect: RepairDialect;
87
+ all(sql: string): Promise<unknown[]>;
88
+ run(sql: string): Promise<number>;
89
+ }
90
+
91
+ /**
92
+ * The declared pending state, not absence (#45). It must never fold into
93
+ * `personal`, and it is the one value the repair refuses to write over a
94
+ * classified row.
95
+ */
96
+ const PENDING = "uncategorized";
97
+
98
+ const nowMillis = (dialect: RepairDialect): string =>
99
+ dialect === "sqlite"
100
+ ? "CAST(unixepoch('subsec') * 1000 AS INTEGER)"
101
+ : "CAST(EXTRACT(EPOCH FROM now()) * 1000 AS bigint)";
102
+
103
+ export const repairStatement = (dialect: RepairDialect): string =>
104
+ `UPDATE thread_message
105
+ SET category = (
106
+ SELECT m.category FROM message m WHERE m.message_id = thread_message.message_id
107
+ )
108
+ WHERE EXISTS (
109
+ SELECT 1 FROM message m
110
+ WHERE m.message_id = thread_message.message_id
111
+ AND m.category <> thread_message.category
112
+ AND m.category <> '${PENDING}'
113
+ )
114
+ AND thread_message.updated_at <= ${nowMillis(dialect)}`;
115
+
116
+ const BUCKETS_SQL = `SELECT t.category AS row_category, m.category AS message_category, count(*) AS row_count
117
+ FROM thread_message t
118
+ JOIN message m ON m.message_id = t.message_id
119
+ GROUP BY t.category, m.category`;
120
+
121
+ const TALLY_SQL = `SELECT category, count(*) AS row_count
122
+ FROM thread_message
123
+ GROUP BY category
124
+ ORDER BY row_count DESC, category ASC`;
125
+
126
+ const BY_MAILBOX_SQL = `SELECT t.mailbox_id AS mailbox_id, count(*) AS row_count
127
+ FROM thread_message t
128
+ JOIN message m ON m.message_id = t.message_id
129
+ WHERE m.category <> t.category
130
+ GROUP BY t.mailbox_id
131
+ ORDER BY row_count DESC, mailbox_id ASC`;
132
+
133
+ const FAN_OUT_SQL = `SELECT count(*) AS message_count, coalesce(sum(row_count), 0) AS row_total
134
+ FROM (
135
+ SELECT message_id, count(*) AS row_count
136
+ FROM thread_message
137
+ GROUP BY message_id
138
+ HAVING count(*) > 1
139
+ ) fan_out`;
140
+
141
+ /**
142
+ * The residual after a repair, split by why the row was left. A row skipped
143
+ * because a writer touched it during the statement is repaired on the next run;
144
+ * a row whose `updated_at` is ahead of the database clock fails the guard on
145
+ * every run until the stamp is corrected, which is a different thing to tell an
146
+ * operator and would otherwise be indistinguishable in the output.
147
+ */
148
+ const residualSql = (dialect: RepairDialect): string =>
149
+ `SELECT count(*) AS row_count,
150
+ coalesce(sum(CASE WHEN t.updated_at > ${nowMillis(dialect)} THEN 1 ELSE 0 END), 0) AS ahead_of_clock
151
+ FROM thread_message t
152
+ JOIN message m ON m.message_id = t.message_id
153
+ WHERE m.category <> t.category
154
+ AND m.category <> '${PENDING}'`;
155
+
156
+ export type CategoryCount = {
157
+ readonly category: string;
158
+ readonly rows: number;
159
+ };
160
+
161
+ export type MailboxCount = {
162
+ readonly mailboxId: string;
163
+ readonly rows: number;
164
+ };
165
+
166
+ /**
167
+ * Divergence split by direction, because the direction is the cause. A repair
168
+ * that never ran and a corpus that was already correct both report
169
+ * `divergent: 0`; only the per-cause figures tell them apart, and only the
170
+ * per-cause expectations say which zeros are the healthy answer.
171
+ *
172
+ * - `behind` — the row is pending, its message is classified. Every one of the
173
+ * three write-path defects #326 fixes lands here, and this is the cohort the
174
+ * repair exists for.
175
+ * - `crossed` — both classified, differently. No write path leaves a row here:
176
+ * the row and the message take the same `classifyByHeaders` value off the
177
+ * same bytes in the same pass, so a re-classification moves both.
178
+ * - `ahead` — the row is classified, its message is pending. After #326 that is
179
+ * a classification in flight, so on a live instance it is expected and
180
+ * transient. Not repaired.
181
+ */
182
+ export type CategoryDivergence = {
183
+ readonly rowsWithMessage: number;
184
+ readonly divergent: number;
185
+ readonly repairable: number;
186
+ readonly behind: number;
187
+ readonly crossed: number;
188
+ readonly ahead: number;
189
+ readonly notYetClassified: number;
190
+ };
191
+
192
+ export type CategoryResidual = {
193
+ readonly repairable: number;
194
+ readonly aheadOfClock: number;
195
+ };
196
+
197
+ export type CategoryDivergenceReport = CategoryDivergence & {
198
+ readonly rows: number;
199
+ readonly orphanRows: number;
200
+ readonly fanOutMessages: number;
201
+ readonly fanOutRows: number;
202
+ readonly divergentByMailbox: readonly MailboxCount[];
203
+ readonly categoryTally: readonly CategoryCount[];
204
+ };
205
+
206
+ export type RepairResult = {
207
+ readonly rowsWritten: number;
208
+ readonly elapsedMs: number;
209
+ };
210
+
211
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
212
+ typeof value === "object" && value !== null;
213
+
214
+ const columnOf = (row: unknown, column: string): unknown => {
215
+ if (!isRecord(row)) {
216
+ throw new Error(
217
+ `thread_message.category check: expected a row object, received ${JSON.stringify(row)}`,
218
+ );
219
+ }
220
+ return row[column];
221
+ };
222
+
223
+ // node-postgres hands back bigint aggregates as strings, so a count is a number
224
+ // or the decimal text of one, and anything else is a query that changed shape.
225
+ const countOf = (row: unknown, column: string): number => {
226
+ const value = columnOf(row, column);
227
+ if (typeof value === "number" && Number.isFinite(value)) {
228
+ return value;
229
+ }
230
+ if (typeof value === "string" && /^\d+$/.test(value)) {
231
+ return Number(value);
232
+ }
233
+ throw new Error(
234
+ `thread_message.category check: expected a count in "${column}", received ${JSON.stringify(value)}`,
235
+ );
236
+ };
237
+
238
+ const textOf = (row: unknown, column: string): string => {
239
+ const value = columnOf(row, column);
240
+ if (typeof value === "string") {
241
+ return value;
242
+ }
243
+ throw new Error(
244
+ `thread_message.category check: expected text in "${column}", received ${JSON.stringify(value)}`,
245
+ );
246
+ };
247
+
248
+ const sum = (values: readonly number[]): number =>
249
+ values.reduce((total, value) => total + value, 0);
250
+
251
+ /**
252
+ * One grouped scan of the join answers every divergence figure, so the check
253
+ * costs two aggregate passes over `thread_message` rather than one per number.
254
+ */
255
+ export const readDivergence = async (
256
+ client: RepairSqlClient,
257
+ ): Promise<CategoryDivergence> => {
258
+ const buckets = (await client.all(BUCKETS_SQL)).map((row) => ({
259
+ rowCategory: textOf(row, "row_category"),
260
+ messageCategory: textOf(row, "message_category"),
261
+ rows: countOf(row, "row_count"),
262
+ }));
263
+
264
+ const total = (
265
+ predicate: (bucket: (typeof buckets)[number]) => boolean,
266
+ ): number => sum(buckets.filter(predicate).map((bucket) => bucket.rows));
267
+
268
+ const behind = total(
269
+ (bucket) =>
270
+ bucket.rowCategory === PENDING && bucket.messageCategory !== PENDING,
271
+ );
272
+ const crossed = total(
273
+ (bucket) =>
274
+ bucket.rowCategory !== PENDING &&
275
+ bucket.messageCategory !== PENDING &&
276
+ bucket.rowCategory !== bucket.messageCategory,
277
+ );
278
+ const ahead = total(
279
+ (bucket) =>
280
+ bucket.rowCategory !== PENDING && bucket.messageCategory === PENDING,
281
+ );
282
+
283
+ return {
284
+ rowsWithMessage: total(() => true),
285
+ divergent: total((bucket) => bucket.rowCategory !== bucket.messageCategory),
286
+ repairable: behind + crossed,
287
+ behind,
288
+ crossed,
289
+ ahead,
290
+ notYetClassified: total(
291
+ (bucket) =>
292
+ bucket.rowCategory === PENDING && bucket.messageCategory === PENDING,
293
+ ),
294
+ };
295
+ };
296
+
297
+ export const checkThreadMessageCategory = async (
298
+ client: RepairSqlClient,
299
+ ): Promise<CategoryDivergenceReport> => {
300
+ const divergence = await readDivergence(client);
301
+
302
+ const categoryTally = (await client.all(TALLY_SQL)).map((row) => ({
303
+ category: textOf(row, "category"),
304
+ rows: countOf(row, "row_count"),
305
+ }));
306
+
307
+ const divergentByMailbox = (await client.all(BY_MAILBOX_SQL)).map((row) => ({
308
+ mailboxId: textOf(row, "mailbox_id"),
309
+ rows: countOf(row, "row_count"),
310
+ }));
311
+
312
+ const [fanOut] = await client.all(FAN_OUT_SQL);
313
+
314
+ const rows = sum(categoryTally.map((entry) => entry.rows));
315
+
316
+ return {
317
+ ...divergence,
318
+ rows,
319
+ orphanRows: rows - divergence.rowsWithMessage,
320
+ fanOutMessages: countOf(fanOut, "message_count"),
321
+ fanOutRows: countOf(fanOut, "row_total"),
322
+ divergentByMailbox,
323
+ categoryTally,
324
+ };
325
+ };
326
+
327
+ export const repairThreadMessageCategory = async (
328
+ client: RepairSqlClient,
329
+ ): Promise<RepairResult> => {
330
+ const startedAt = Date.now();
331
+ const rowsWritten = await client.run(repairStatement(client.dialect));
332
+ return { rowsWritten, elapsedMs: Date.now() - startedAt };
333
+ };
334
+
335
+ export const readResidual = async (
336
+ client: RepairSqlClient,
337
+ ): Promise<CategoryResidual> => {
338
+ const [row] = await client.all(residualSql(client.dialect));
339
+ return {
340
+ repairable: countOf(row, "row_count"),
341
+ aheadOfClock: countOf(row, "ahead_of_clock"),
342
+ };
343
+ };
344
+
345
+ const plural = (rows: number): string => (rows === 1 ? "row" : "rows");
346
+
347
+ /**
348
+ * Every figure carries the cause it measures and the result a healthy instance
349
+ * is expected to produce. Most of them are legitimately zero, so a bare `0`
350
+ * cannot be told apart from a repair that never ran — and a cause an operator
351
+ * can rule out by inspection is worse than no cause at all, because it reads as
352
+ * a broken figure.
353
+ */
354
+ export const formatCheckReport = (
355
+ report: CategoryDivergenceReport,
356
+ ): string[] => [
357
+ `thread_message rows: ${report.rows} (${report.rowsWithMessage} with a message row)`,
358
+ `divergent (thread_message.category <> message.category): ${report.divergent}, of which ${report.repairable} repairable`,
359
+ ` behind: ${report.behind} ${plural(report.behind)} pending against a classified message — the copy of a decided category never landed. All three of the write-path defects #326 fixes end here: a retro classification stranded between its two writes, a denormalize that wrote one of several rows for a message, and a row created for a message that was already classified. Repaired, and this is the cohort the repair exists for. Expected non-zero on an instance where any of those fired, zero otherwise.`,
360
+ ` crossed: ${report.crossed} ${plural(report.crossed)} classified differently from the message — no write path leaves a row here: both take the same classifyByHeaders value off the same bytes in the same pass, so a re-classification moves the row and the message together. Repaired. Expected zero; a non-zero means the pair was interrupted between its two writes — a forced re-fetch that re-decided the category and died before the message write — or the database was edited outside the app.`,
361
+ ` ahead: ${report.ahead} ${plural(report.ahead)} classified against a pending message — after #326 body-sync writes the row before the message, so this is a classification in flight. Not repaired: pushing it back to pending would undo a correct classification and serve Unclassified for mail that is already classified. Expected non-zero on a live instance mid-sync, zero on a quiescent one.`,
362
+ `fan-out: ${report.fanOutMessages} messages holding ${report.fanOutRows} thread_message rows — the multi-row shape #326 hardens against. Expected zero: deriveMessageId and deriveThreadMessageId are both mailbox-independent, so a message in two mailboxes collapses to one row, and the reachable case is thread-root drift.`,
363
+ `orphans: ${report.orphanRows} ${plural(report.orphanRows)} with no message row — not repaired, nothing to copy. Expected zero.`,
364
+ `not-yet-classified: ${report.notYetClassified} ${plural(report.notYetClassified)} pending against a pending message — not classified yet. Not a defect, not touched by the repair, and unchanged by it. Expected non-zero on a live instance.`,
365
+ `divergent per mailbox: ${
366
+ report.divergentByMailbox.length === 0
367
+ ? "none"
368
+ : report.divergentByMailbox
369
+ .map((entry) => `${entry.mailboxId}=${entry.rows}`)
370
+ .join(" ")
371
+ }`,
372
+ `category tally: ${
373
+ report.categoryTally.length === 0
374
+ ? "none"
375
+ : report.categoryTally
376
+ .map((entry) => `${entry.category}=${entry.rows}`)
377
+ .join(" ")
378
+ }`,
379
+ ];
380
+
381
+ /**
382
+ * The next run of the repair is the next start of the migrate one-shot, which on
383
+ * a self-host instance means the next `remit update` — weeks, not minutes. A
384
+ * residual is therefore named as something an operator may have to act on, not
385
+ * as something that clears itself shortly.
386
+ */
387
+ export const formatRepairResult = (
388
+ result: RepairResult,
389
+ residual: CategoryResidual,
390
+ ): string[] => {
391
+ const lines = [
392
+ `repair wrote ${result.rowsWritten} ${plural(result.rowsWritten)} in ${result.elapsedMs}ms`,
393
+ `residual repairable divergence: ${residual.repairable} (expected zero)`,
394
+ ];
395
+ if (residual.repairable === 0) {
396
+ return lines;
397
+ }
398
+
399
+ const concurrent = residual.repairable - residual.aheadOfClock;
400
+ if (concurrent > 0) {
401
+ lines.push(
402
+ `WARNING: ${concurrent} ${plural(concurrent)} skipped because a writer touched them while the repair ran. The writer's value stands, which is correct, but the copy is only rechecked the next time this one-shot runs — the next 'remit update' — and until then the mail list serves that row's category as it now stands.`,
403
+ );
404
+ }
405
+ if (residual.aheadOfClock > 0) {
406
+ lines.push(
407
+ `WARNING: ${residual.aheadOfClock} ${plural(residual.aheadOfClock)} carry an updated_at ahead of the database clock, so they fail the repair's guard on every run, not just this one. That is a clock that jumped, a restored backup, or a host whose time was wrong — not a concurrent writer. They stay divergent until the stamp is corrected.`,
408
+ );
409
+ }
410
+ return lines;
411
+ };
412
+
413
+ /**
414
+ * The repair is skipped outright when the check found nothing to write, because
415
+ * SQLite takes the exclusive write lock when an UPDATE begins — before it can
416
+ * know the WHERE matches nothing. The steady state is zero, so without this
417
+ * every boot would take that lock, and a lock it cannot acquire within
418
+ * busy_timeout fails the migration and holds every gated service down. The
419
+ * figures the skip is decided on were just read; a row that diverges in the
420
+ * moment between is skipped by the guard anyway and repaired on the next run.
421
+ */
422
+ export const formatRepairSkipped = (
423
+ divergence: CategoryDivergence,
424
+ ): string[] => [
425
+ `repair skipped: nothing repairable (${divergence.divergent} divergent, ${divergence.ahead} of them a classification in flight). No write, so no write lock is taken.`,
426
+ ];