@remit/drizzle-service 0.0.99 → 0.0.101

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 +46 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +10 -9
  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 +32 -106
  42. package/src/repos/message.test.ts +6 -167
  43. package/src/repos/message.ts +216 -228
  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,415 +0,0 @@
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. 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. Writers are serialized, so a concurrent
37
- * body-sync write lands wholly before or wholly after and both orders
38
- * converge on the same value. The `updated_at` guard is the backstop: a row
39
- * a concurrent writer touched after the statement began fails the re-check,
40
- * so the writer's value stands rather than being reverted.
41
- *
42
- * The guard covers a writer whose transaction begins after the statement. A
43
- * writer that began before it and commits during it carries an older stamp and
44
- * passes, which would revert its write. Four things have to hold at once for
45
- * that to lose anything:
46
- *
47
- * 1. a row the statement's WHERE matches, so an unrepaired `behind` row;
48
- * 2. a writer overlapping the statement;
49
- * 3. that writer's row write committing before the statement began while its
50
- * message write commits after it — the two are separate commits;
51
- * 4. the writer moving `message.category` from one decided value to a
52
- * *different* decided value. A first classification cannot lose anything:
53
- * `message.category` is still `uncategorized`, so the pending exclusion
54
- * above refuses the row outright.
55
- *
56
- * Only the fourth is interesting, and it is not impossible. It is reachable
57
- * through the one path that classifies, which is re-enterable: the skip guard
58
- * is `if (hasStoredBody(message.bodyStorageKey) && !force)` (`body-sync.ts`), and
59
- * `force` is a live event flag — the read-miss re-arm cue, resolved in the
60
- * imap-worker's `sync-message-body.ts`. A forced re-fetch re-runs
61
- * `classifyByHeaders` over the same bytes, so it normally rewrites the value it
62
- * already held; it decides *differently* only across a release that changed the
63
- * classifier, which #62 was.
64
- *
65
- * So: effectively unreachable, needing a forced re-fetch of a message whose
66
- * stored classification predates a classifier change, overlapping this
67
- * statement. Not impossible, and the `crossed` figure is what reports it.
68
- *
69
- * The statement does not touch `updated_at`. That column means "when the app
70
- * last wrote this row", which is what the guard above reads, and a repair that
71
- * bumped it would both lie about the row and defeat its own guard.
72
- */
73
-
74
- /**
75
- * The smallest surface the repair needs, so this module imports nothing and can
76
- * be driven by the migrator's `better-sqlite3` handle or a test harness.
77
- */
78
- export interface RepairSqlClient {
79
- all(sql: string): Promise<unknown[]>;
80
- run(sql: string): Promise<number>;
81
- }
82
-
83
- /**
84
- * The declared pending state, not absence (#45). It must never fold into
85
- * `personal`, and it is the one value the repair refuses to write over a
86
- * classified row.
87
- */
88
- const PENDING = "uncategorized";
89
-
90
- const NOW_MILLIS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)";
91
-
92
- export const repairStatement = (): string =>
93
- `UPDATE thread_message
94
- SET category = (
95
- SELECT m.category FROM message m WHERE m.message_id = thread_message.message_id
96
- )
97
- WHERE EXISTS (
98
- SELECT 1 FROM message m
99
- WHERE m.message_id = thread_message.message_id
100
- AND m.category <> thread_message.category
101
- AND m.category <> '${PENDING}'
102
- )
103
- AND thread_message.updated_at <= ${NOW_MILLIS}`;
104
-
105
- const BUCKETS_SQL = `SELECT t.category AS row_category, m.category AS message_category, count(*) AS row_count
106
- FROM thread_message t
107
- JOIN message m ON m.message_id = t.message_id
108
- GROUP BY t.category, m.category`;
109
-
110
- const TALLY_SQL = `SELECT category, count(*) AS row_count
111
- FROM thread_message
112
- GROUP BY category
113
- ORDER BY row_count DESC, category ASC`;
114
-
115
- const BY_MAILBOX_SQL = `SELECT t.mailbox_id AS mailbox_id, count(*) AS row_count
116
- FROM thread_message t
117
- JOIN message m ON m.message_id = t.message_id
118
- WHERE m.category <> t.category
119
- GROUP BY t.mailbox_id
120
- ORDER BY row_count DESC, mailbox_id ASC`;
121
-
122
- const FAN_OUT_SQL = `SELECT count(*) AS message_count, coalesce(sum(row_count), 0) AS row_total
123
- FROM (
124
- SELECT message_id, count(*) AS row_count
125
- FROM thread_message
126
- GROUP BY message_id
127
- HAVING count(*) > 1
128
- ) fan_out`;
129
-
130
- /**
131
- * The residual after a repair, split by why the row was left. A row skipped
132
- * because a writer touched it during the statement is repaired on the next run;
133
- * a row whose `updated_at` is ahead of the database clock fails the guard on
134
- * every run until the stamp is corrected, which is a different thing to tell an
135
- * operator and would otherwise be indistinguishable in the output.
136
- */
137
- const residualSql = (): string =>
138
- `SELECT count(*) AS row_count,
139
- coalesce(sum(CASE WHEN t.updated_at > ${NOW_MILLIS} THEN 1 ELSE 0 END), 0) AS ahead_of_clock
140
- FROM thread_message t
141
- JOIN message m ON m.message_id = t.message_id
142
- WHERE m.category <> t.category
143
- AND m.category <> '${PENDING}'`;
144
-
145
- export type CategoryCount = {
146
- readonly category: string;
147
- readonly rows: number;
148
- };
149
-
150
- export type MailboxCount = {
151
- readonly mailboxId: string;
152
- readonly rows: number;
153
- };
154
-
155
- /**
156
- * Divergence split by direction, because the direction is the cause. A repair
157
- * that never ran and a corpus that was already correct both report
158
- * `divergent: 0`; only the per-cause figures tell them apart, and only the
159
- * per-cause expectations say which zeros are the healthy answer.
160
- *
161
- * - `behind` — the row is pending, its message is classified. Every one of the
162
- * three write-path defects #326 fixes lands here, and this is the cohort the
163
- * repair exists for.
164
- * - `crossed` — both classified, differently. No write path leaves a row here:
165
- * the row and the message take the same `classifyByHeaders` value off the
166
- * same bytes in the same pass, so a re-classification moves both.
167
- * - `ahead` — the row is classified, its message is pending. After #326 that is
168
- * a classification in flight, so on a live instance it is expected and
169
- * transient. Not repaired.
170
- */
171
- export type CategoryDivergence = {
172
- readonly rowsWithMessage: number;
173
- readonly divergent: number;
174
- readonly repairable: number;
175
- readonly behind: number;
176
- readonly crossed: number;
177
- readonly ahead: number;
178
- readonly notYetClassified: number;
179
- };
180
-
181
- export type CategoryResidual = {
182
- readonly repairable: number;
183
- readonly aheadOfClock: number;
184
- };
185
-
186
- export type CategoryDivergenceReport = CategoryDivergence & {
187
- readonly rows: number;
188
- readonly orphanRows: number;
189
- readonly fanOutMessages: number;
190
- readonly fanOutRows: number;
191
- readonly divergentByMailbox: readonly MailboxCount[];
192
- readonly categoryTally: readonly CategoryCount[];
193
- };
194
-
195
- export type RepairResult = {
196
- readonly rowsWritten: number;
197
- readonly elapsedMs: number;
198
- };
199
-
200
- const isRecord = (value: unknown): value is Record<string, unknown> =>
201
- typeof value === "object" && value !== null;
202
-
203
- const columnOf = (row: unknown, column: string): unknown => {
204
- if (!isRecord(row)) {
205
- throw new Error(
206
- `thread_message.category check: expected a row object, received ${JSON.stringify(row)}`,
207
- );
208
- }
209
- return row[column];
210
- };
211
-
212
- // A driver may hand back a bigint aggregate as a string, so a count is a number
213
- // or the decimal text of one, and anything else is a query that changed shape.
214
- const countOf = (row: unknown, column: string): number => {
215
- const value = columnOf(row, column);
216
- if (typeof value === "number" && Number.isFinite(value)) {
217
- return value;
218
- }
219
- if (typeof value === "string" && /^\d+$/.test(value)) {
220
- return Number(value);
221
- }
222
- throw new Error(
223
- `thread_message.category check: expected a count in "${column}", received ${JSON.stringify(value)}`,
224
- );
225
- };
226
-
227
- const textOf = (row: unknown, column: string): string => {
228
- const value = columnOf(row, column);
229
- if (typeof value === "string") {
230
- return value;
231
- }
232
- throw new Error(
233
- `thread_message.category check: expected text in "${column}", received ${JSON.stringify(value)}`,
234
- );
235
- };
236
-
237
- const sum = (values: readonly number[]): number =>
238
- values.reduce((total, value) => total + value, 0);
239
-
240
- /**
241
- * One grouped scan of the join answers every divergence figure, so the check
242
- * costs two aggregate passes over `thread_message` rather than one per number.
243
- */
244
- export const readDivergence = async (
245
- client: RepairSqlClient,
246
- ): Promise<CategoryDivergence> => {
247
- const buckets = (await client.all(BUCKETS_SQL)).map((row) => ({
248
- rowCategory: textOf(row, "row_category"),
249
- messageCategory: textOf(row, "message_category"),
250
- rows: countOf(row, "row_count"),
251
- }));
252
-
253
- const total = (
254
- predicate: (bucket: (typeof buckets)[number]) => boolean,
255
- ): number => sum(buckets.filter(predicate).map((bucket) => bucket.rows));
256
-
257
- const behind = total(
258
- (bucket) =>
259
- bucket.rowCategory === PENDING && bucket.messageCategory !== PENDING,
260
- );
261
- const crossed = total(
262
- (bucket) =>
263
- bucket.rowCategory !== PENDING &&
264
- bucket.messageCategory !== PENDING &&
265
- bucket.rowCategory !== bucket.messageCategory,
266
- );
267
- const ahead = total(
268
- (bucket) =>
269
- bucket.rowCategory !== PENDING && bucket.messageCategory === PENDING,
270
- );
271
-
272
- return {
273
- rowsWithMessage: total(() => true),
274
- divergent: total((bucket) => bucket.rowCategory !== bucket.messageCategory),
275
- repairable: behind + crossed,
276
- behind,
277
- crossed,
278
- ahead,
279
- notYetClassified: total(
280
- (bucket) =>
281
- bucket.rowCategory === PENDING && bucket.messageCategory === PENDING,
282
- ),
283
- };
284
- };
285
-
286
- export const checkThreadMessageCategory = async (
287
- client: RepairSqlClient,
288
- ): Promise<CategoryDivergenceReport> => {
289
- const divergence = await readDivergence(client);
290
-
291
- const categoryTally = (await client.all(TALLY_SQL)).map((row) => ({
292
- category: textOf(row, "category"),
293
- rows: countOf(row, "row_count"),
294
- }));
295
-
296
- const divergentByMailbox = (await client.all(BY_MAILBOX_SQL)).map((row) => ({
297
- mailboxId: textOf(row, "mailbox_id"),
298
- rows: countOf(row, "row_count"),
299
- }));
300
-
301
- const [fanOut] = await client.all(FAN_OUT_SQL);
302
-
303
- const rows = sum(categoryTally.map((entry) => entry.rows));
304
-
305
- return {
306
- ...divergence,
307
- rows,
308
- orphanRows: rows - divergence.rowsWithMessage,
309
- fanOutMessages: countOf(fanOut, "message_count"),
310
- fanOutRows: countOf(fanOut, "row_total"),
311
- divergentByMailbox,
312
- categoryTally,
313
- };
314
- };
315
-
316
- export const repairThreadMessageCategory = async (
317
- client: RepairSqlClient,
318
- ): Promise<RepairResult> => {
319
- const startedAt = Date.now();
320
- const rowsWritten = await client.run(repairStatement());
321
- return { rowsWritten, elapsedMs: Date.now() - startedAt };
322
- };
323
-
324
- export const readResidual = async (
325
- client: RepairSqlClient,
326
- ): Promise<CategoryResidual> => {
327
- const [row] = await client.all(residualSql());
328
- return {
329
- repairable: countOf(row, "row_count"),
330
- aheadOfClock: countOf(row, "ahead_of_clock"),
331
- };
332
- };
333
-
334
- const plural = (rows: number): string => (rows === 1 ? "row" : "rows");
335
-
336
- /**
337
- * Every figure carries the cause it measures and the result a healthy instance
338
- * is expected to produce. Most of them are legitimately zero, so a bare `0`
339
- * cannot be told apart from a repair that never ran — and a cause an operator
340
- * can rule out by inspection is worse than no cause at all, because it reads as
341
- * a broken figure.
342
- */
343
- export const formatCheckReport = (
344
- report: CategoryDivergenceReport,
345
- ): string[] => [
346
- `thread_message rows: ${report.rows} (${report.rowsWithMessage} with a message row)`,
347
- `divergent (thread_message.category <> message.category): ${report.divergent}, of which ${report.repairable} repairable`,
348
- ` 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.`,
349
- ` 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.`,
350
- ` 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.`,
351
- `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.`,
352
- `orphans: ${report.orphanRows} ${plural(report.orphanRows)} with no message row — not repaired, nothing to copy. Expected zero.`,
353
- `uncategorized: ${report.notYetClassified} ${plural(report.notYetClassified)} uncategorized against an uncategorized message — the row agrees with its message, so there is nothing to repair and this figure is untouched by the repair. It is NOT the same as "not classified yet": it mixes mail the classifier has not reached with mail it reached and had nothing to say about. Only \`message.classification_state\` separates them (\`SELECT classification_state, count(*) FROM message WHERE category = 'uncategorized' GROUP BY 1\`), and a large \`NotExamined\` count is the cohort worth acting on. Expected non-zero on a live instance.`,
354
- `divergent per mailbox: ${
355
- report.divergentByMailbox.length === 0
356
- ? "none"
357
- : report.divergentByMailbox
358
- .map((entry) => `${entry.mailboxId}=${entry.rows}`)
359
- .join(" ")
360
- }`,
361
- `category tally: ${
362
- report.categoryTally.length === 0
363
- ? "none"
364
- : report.categoryTally
365
- .map((entry) => `${entry.category}=${entry.rows}`)
366
- .join(" ")
367
- }`,
368
- ];
369
-
370
- /**
371
- * The next run of the repair is the next start of the migrate one-shot, which on
372
- * a self-host instance means the next `remit update` — weeks, not minutes. A
373
- * residual is therefore named as something an operator may have to act on, not
374
- * as something that clears itself shortly.
375
- */
376
- export const formatRepairResult = (
377
- result: RepairResult,
378
- residual: CategoryResidual,
379
- ): string[] => {
380
- const lines = [
381
- `repair wrote ${result.rowsWritten} ${plural(result.rowsWritten)} in ${result.elapsedMs}ms`,
382
- `residual repairable divergence: ${residual.repairable} (expected zero)`,
383
- ];
384
- if (residual.repairable === 0) {
385
- return lines;
386
- }
387
-
388
- const concurrent = residual.repairable - residual.aheadOfClock;
389
- if (concurrent > 0) {
390
- lines.push(
391
- `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.`,
392
- );
393
- }
394
- if (residual.aheadOfClock > 0) {
395
- lines.push(
396
- `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.`,
397
- );
398
- }
399
- return lines;
400
- };
401
-
402
- /**
403
- * The repair is skipped outright when the check found nothing to write, because
404
- * SQLite takes the exclusive write lock when an UPDATE begins — before it can
405
- * know the WHERE matches nothing. The steady state is zero, so without this
406
- * every boot would take that lock, and a lock it cannot acquire within
407
- * busy_timeout fails the migration and holds every gated service down. The
408
- * figures the skip is decided on were just read; a row that diverges in the
409
- * moment between is skipped by the guard anyway and repaired on the next run.
410
- */
411
- export const formatRepairSkipped = (
412
- divergence: CategoryDivergence,
413
- ): string[] => [
414
- `repair skipped: nothing repairable (${divergence.divergent} divergent, ${divergence.ahead} of them a classification in flight). No write, so no write lock is taken.`,
415
- ];