@remit/drizzle-service 0.0.99 → 0.0.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/batch.sqlite.test.ts +78 -0
- package/src/batch.ts +45 -0
- package/src/databases.ts +70 -0
- package/src/db.ts +1 -4
- package/src/generated-enum.test.ts +8 -0
- package/src/generated-id.test.ts +19 -0
- package/src/id.test.ts +0 -8
- package/src/id.ts +0 -6
- package/src/index.ts +16 -6
- package/src/repair/address-display-name.sqlite.test.ts +3 -5
- package/src/repair/address-display-name.ts +8 -3
- package/src/repair/junk-only-address.sqlite.test.ts +8 -36
- package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
- package/src/repos/account.conformance.sqlite.test.ts +1 -1
- package/src/repos/calendar-default.sqlite.test.ts +6 -6
- package/src/repos/calendar-put.sqlite.test.ts +40 -39
- package/src/repos/calendar-store.ts +273 -0
- package/src/repos/cascade-delete.sqlite.test.ts +7 -9
- package/src/repos/cascade-delete.test.ts +6 -13
- package/src/repos/cascade-delete.ts +127 -97
- package/src/repos/envelope.test.ts +1 -17
- package/src/repos/envelope.ts +83 -67
- package/src/repos/filter.test.ts +22 -2
- package/src/repos/filter.ts +1 -5
- package/src/repos/i4-account-config.test.ts +2 -2
- package/src/repos/i4-account-config.ts +5 -2
- package/src/repos/i4-account.test.ts +6 -2
- package/src/repos/i4-account.ts +12 -56
- package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
- package/src/repos/i4-address.ts +58 -90
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
- package/src/repos/i4-mailbox-special-use.ts +9 -6
- package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
- package/src/repos/i4-mailbox.test.ts +17 -62
- package/src/repos/i4-mailbox.ts +121 -94
- package/src/repos/i4-organize-job-request.ts +0 -4
- package/src/repos/i4-outbox-attachment.ts +62 -33
- package/src/repos/mappers.ts +0 -16
- package/src/repos/message.sqlite.test.ts +32 -106
- package/src/repos/message.test.ts +6 -167
- package/src/repos/message.ts +216 -228
- package/src/repos/sync-warning.ts +76 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
- package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
- package/src/repos/thread-message.sqlite.test.ts +2 -1
- package/src/repos/thread-message.test.ts +17 -4
- package/src/repos/thread-message.ts +502 -285
- package/src/repos/thread-search-predicates.ts +24 -26
- package/src/schema/message-data.ts +4 -8
- package/src/schema/sync-warning.ts +3 -0
- package/src/schema-sets/app.ts +17 -0
- package/src/schema-sets/mail.ts +32 -0
- package/src/schema-sets/search.ts +5 -0
- package/src/schema-version-accounting.sqlite.test.ts +56 -43
- package/src/schema.ts +3 -4
- package/src/search/thread-message-search.sqlite.test.ts +131 -0
- package/src/search/thread-message-search.ts +271 -0
- package/src/sqlite-client.ts +5 -7
- package/src/sqlite-repositories.ts +75 -0
- package/src/test-db-sqlite.ts +5 -13
- package/src/test-shipped-sqlite-schema.ts +46 -45
- package/src/test-sqlite.ts +35 -11
- package/src/vps-migrations-drift.sqlite.test.ts +81 -15
- package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
- package/src/account-synced-services-migration.sqlite.test.ts +0 -56
- package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
- package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
- package/src/repair/duplicate-mailbox-mail.ts +0 -98
- package/src/repair/thread-message-category-contract.ts +0 -505
- package/src/repair/thread-message-category.sqlite.test.ts +0 -100
- package/src/repair/thread-message-category.ts +0 -415
- package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
- package/src/repos/calendar-unit-of-work.ts +0 -31
- package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
- package/src/repos/filter-anchor-transaction.test.ts +0 -136
- package/src/repos/filter-anchor-transaction.ts +0 -45
- package/src/repos/filter-anchor.test.ts +0 -131
- package/src/repos/filter-anchor.ts +0 -105
- package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
- package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
- package/src/repos/search-index-shape.test.ts +0 -82
- package/src/repos/search-index-shape.ts +0 -56
- package/src/repos/serialized-writes.sqlite.test.ts +0 -199
- package/src/repos/unit-of-work.sqlite.test.ts +0 -138
- package/src/repos/unit-of-work.test.ts +0 -109
- package/src/repos/unit-of-work.ts +0 -31
- package/src/schema/outbox.ts +0 -46
- package/src/schema-full-sqlite.ts +0 -13
- package/src/tx.sqlite.test.ts +0 -83
- 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
|
-
];
|