@remit/drizzle-service 0.0.81 → 0.0.83

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.81",
3
+ "version": "0.0.83",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The guard that keeps an instance installed by an earlier build from running a
3
+ * predicate its index cannot answer.
4
+ *
5
+ * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table, so
6
+ * adding an indexed column changes nothing on a database that already has the
7
+ * old one — and a MATCH naming that column raises rather than missing. The
8
+ * migrator compares the shipped shape with the installed one and rebuilds.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { readFileSync } from "node:fs";
12
+ import { describe, test } from "node:test";
13
+ import {
14
+ searchIndexColumns,
15
+ searchIndexShapeIsCurrent,
16
+ } from "./search-index-shape.js";
17
+
18
+ const shipped = readFileSync(
19
+ new URL("../../../../npm-scripts/sqlite-search-index.sql", import.meta.url),
20
+ "utf8",
21
+ );
22
+
23
+ const installed = (columns: string[]): string =>
24
+ `CREATE VIRTUAL TABLE thread_message_fts USING fts5(${columns.join(", ")}, content='thread_message', content_rowid='rowid', tokenize='trigram remove_diacritics 1')`;
25
+
26
+ describe("searchIndexColumns", () => {
27
+ test("reads the indexed columns and leaves the options out", () => {
28
+ assert.deepEqual(searchIndexColumns(installed(["subject", "sender"])), [
29
+ "subject",
30
+ "sender",
31
+ ]);
32
+ });
33
+
34
+ test("reads the shipped index, triggers and all", () => {
35
+ assert.deepEqual(searchIndexColumns(shipped), [
36
+ "subject",
37
+ "sender",
38
+ "body",
39
+ ]);
40
+ });
41
+
42
+ test("a table that is not an fts5 index has no columns", () => {
43
+ assert.deepEqual(searchIndexColumns("CREATE TABLE t (a, b)"), []);
44
+ });
45
+ });
46
+
47
+ describe("searchIndexShapeIsCurrent", () => {
48
+ test("the shipped index matches itself", () => {
49
+ assert.equal(searchIndexShapeIsCurrent(shipped, shipped), true);
50
+ });
51
+
52
+ // The upgrade this exists for: an instance carrying the two-column index the
53
+ // previous build installed must be rebuilt, or every search raises.
54
+ test("an index missing an indexed column is stale", () => {
55
+ assert.equal(
56
+ searchIndexShapeIsCurrent(installed(["subject", "sender"]), shipped),
57
+ false,
58
+ );
59
+ });
60
+
61
+ test("an index carrying every shipped column is current", () => {
62
+ assert.equal(
63
+ searchIndexShapeIsCurrent(
64
+ installed(["subject", "sender", "body"]),
65
+ shipped,
66
+ ),
67
+ true,
68
+ );
69
+ });
70
+
71
+ // An extra column indexes text nothing reads. It costs space and matches
72
+ // nothing wrong, so it is not a reason to drop and re-tokenize the table.
73
+ test("an index carrying more than the shipped columns is current", () => {
74
+ assert.equal(
75
+ searchIndexShapeIsCurrent(
76
+ installed(["subject", "sender", "body", "labels"]),
77
+ shipped,
78
+ ),
79
+ true,
80
+ );
81
+ });
82
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Whether an installed FTS5 search index carries the columns the shipped one
3
+ * declares.
4
+ *
5
+ * `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against a table that already
6
+ * exists, so a database installed by an earlier build keeps that build's
7
+ * columns for good. A predicate naming a column that build never indexed does
8
+ * not degrade — SQLite raises, and every search on that instance fails — so the
9
+ * migrator compares the two shapes and rebuilds where they differ.
10
+ *
11
+ * Both sides are read from DDL rather than from a hand-kept list: the shipped
12
+ * side is the committed `.sql`, the installed side is what `sqlite_master`
13
+ * holds, and neither can drift from what is actually there.
14
+ */
15
+
16
+ /** The text between the parentheses of a `USING fts5(...)` clause. */
17
+ const fts5Arguments = (ddl: string): string => {
18
+ const clause = ddl.search(/using\s+fts5\s*\(/i);
19
+ if (clause === -1) return "";
20
+ const open = ddl.indexOf("(", clause);
21
+ let depth = 0;
22
+ for (let index = open; index < ddl.length; index++) {
23
+ const char = ddl[index];
24
+ if (char === "(") depth++;
25
+ if (char !== ")") continue;
26
+ depth--;
27
+ if (depth === 0) return ddl.slice(open + 1, index);
28
+ }
29
+ return "";
30
+ };
31
+
32
+ /**
33
+ * The indexed column names an FTS5 table declares. An argument carrying an `=`
34
+ * is an option (`content=`, `tokenize=`), not a column, and anything quoted or
35
+ * otherwise unusual is left out rather than guessed at — a name this cannot
36
+ * read is a name the comparison must not claim is missing.
37
+ */
38
+ export const searchIndexColumns = (ddl: string): string[] =>
39
+ fts5Arguments(ddl)
40
+ .split(",")
41
+ .map((argument) => argument.trim())
42
+ .filter((argument) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(argument));
43
+
44
+ /**
45
+ * Whether `installed` indexes every column `shipped` does. A superset passes:
46
+ * an extra column indexes text no predicate reads, which costs space and
47
+ * matches nothing wrong.
48
+ */
49
+ export const searchIndexShapeIsCurrent = (
50
+ installed: string,
51
+ shipped: string,
52
+ ): boolean => {
53
+ const columns = new Set(searchIndexColumns(installed));
54
+ const expected = searchIndexColumns(shipped);
55
+ return expected.length > 0 && expected.every((column) => columns.has(column));
56
+ };
@@ -0,0 +1,353 @@
1
+ /**
2
+ * The two criteria the unified listing could not previously ask about: the body
3
+ * text a message carries, and whether its sender is muted.
4
+ *
5
+ * Both were client passes over the rows a page had already fetched, so both
6
+ * answered "among the mail loaded so far" while being presented as answers about
7
+ * the collection. The fixture puts the row that matters below the newest page in
8
+ * each case, which is exactly what such a pass cannot see (#1135, #1137).
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { readFileSync } from "node:fs";
12
+ import { after, before, describe, test } from "node:test";
13
+ import type { CreateThreadMessageInput } from "@remit/data-ports";
14
+ import { addressTable } from "../schema/i4-address.js";
15
+ import { threadMessageTable } from "../schema/thread-message.js";
16
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
17
+ import { DrizzleThreadMessageRepository } from "./thread-message.js";
18
+
19
+ // The index the migrator installs for this predicate, read from the one
20
+ // committed source so the plan assertion below is about the real object.
21
+ const mutedIndexDdl = readFileSync(
22
+ new URL(
23
+ "../../../../npm-scripts/sqlite-address-muted-index.sql",
24
+ import.meta.url,
25
+ ),
26
+ "utf8",
27
+ );
28
+
29
+ const ACCOUNT = "acct-seam";
30
+ const MAILBOX = "mbx-seam";
31
+ const SCOPE = new Set([MAILBOX]);
32
+
33
+ const BASE_DATE = 1_700_000_000_000;
34
+
35
+ let sequence = 0;
36
+ const makeInput = (
37
+ overrides: Partial<CreateThreadMessageInput> = {},
38
+ ): CreateThreadMessageInput => {
39
+ sequence += 1;
40
+ return {
41
+ accountConfigId: ACCOUNT,
42
+ threadId: `t-${sequence}`,
43
+ messageId: `m-${sequence}`,
44
+ mailboxId: MAILBOX,
45
+ uid: sequence,
46
+ referenceOrder: 0,
47
+ internalDate: BASE_DATE,
48
+ sentDate: BASE_DATE,
49
+ isRead: false,
50
+ isDeleted: false,
51
+ hasAttachment: false,
52
+ hasStars: false,
53
+ ...overrides,
54
+ };
55
+ };
56
+
57
+ describe("thread-message body and muted-sender predicates (sqlite)", () => {
58
+ let db: Awaited<ReturnType<typeof createSqliteTestDb>>["db"];
59
+ let sqlite: Awaited<ReturnType<typeof createSqliteTestDb>>["sqlite"];
60
+ let close: () => Promise<void>;
61
+ let repo: DrizzleThreadMessageRepository;
62
+
63
+ before(async () => {
64
+ ({ db, sqlite, close } = await createSqliteTestDb(
65
+ { threadMessage: threadMessageTable, address: addressTable },
66
+ { searchIndex: true },
67
+ ));
68
+ sqlite.exec(mutedIndexDdl);
69
+ repo = new DrizzleThreadMessageRepository(db);
70
+ });
71
+
72
+ after(async () => {
73
+ await close();
74
+ });
75
+
76
+ describe("a body-only match", () => {
77
+ before(async () => {
78
+ // Newer noise, so the matching row is not on the newest page.
79
+ for (let index = 0; index < 20; index += 1) {
80
+ await repo.create(
81
+ makeInput({
82
+ subject: "unrelated note",
83
+ fromEmail: "noise@example.com",
84
+ fromName: "Noise",
85
+ snippet: "nothing to see",
86
+ sentDate: BASE_DATE + 1000 + index,
87
+ }),
88
+ );
89
+ }
90
+ await repo.create(
91
+ makeInput({
92
+ subject: "unrelated note",
93
+ fromEmail: "noise@example.com",
94
+ fromName: "Noise",
95
+ snippet: "Your parcel was left with the concierge",
96
+ sentDate: BASE_DATE,
97
+ }),
98
+ );
99
+ });
100
+
101
+ test("the term reaches the body text, not just subject and From", async () => {
102
+ const result = await repo.searchByDate(
103
+ ACCOUNT,
104
+ { query: "concierge" },
105
+ { mailboxIds: SCOPE, limit: 50 },
106
+ );
107
+
108
+ assert.deepEqual(
109
+ result.items.map((item) => item.snippet),
110
+ ["Your parcel was left with the concierge"],
111
+ );
112
+ });
113
+
114
+ // The point of moving it into the query: a page smaller than the noise
115
+ // still returns the match, where a pass over the loaded rows returns
116
+ // nothing until the reader has scrolled past it.
117
+ test("a match below the newest page is still returned", async () => {
118
+ const result = await repo.searchByDate(
119
+ ACCOUNT,
120
+ { query: "concierge" },
121
+ { mailboxIds: SCOPE, limit: 5 },
122
+ );
123
+
124
+ assert.equal(result.items.length, 1);
125
+ });
126
+
127
+ test("a term under the trigram floor reads the body too", async () => {
128
+ const result = await repo.searchByDate(
129
+ ACCOUNT,
130
+ { query: "ge" },
131
+ { mailboxIds: SCOPE, limit: 50 },
132
+ );
133
+
134
+ assert.ok(
135
+ result.items.some((item) => item.snippet?.includes("concierge")),
136
+ "the folded scan matched the body preview",
137
+ );
138
+ });
139
+
140
+ test("every term must still match somewhere", async () => {
141
+ const result = await repo.searchByDate(
142
+ ACCOUNT,
143
+ { query: "concierge unrelated" },
144
+ { mailboxIds: SCOPE, limit: 50 },
145
+ );
146
+ assert.equal(result.items.length, 1);
147
+
148
+ const none = await repo.searchByDate(
149
+ ACCOUNT,
150
+ { query: "concierge zyxwvut" },
151
+ { mailboxIds: SCOPE, limit: 50 },
152
+ );
153
+ assert.equal(none.items.length, 0);
154
+ });
155
+ });
156
+
157
+ describe("the muted-sender term", () => {
158
+ const MUTED_ACCOUNT = "acct-muted";
159
+ const MUTED_MAILBOX = "mbx-muted";
160
+ const MUTED_SCOPE = new Set([MUTED_MAILBOX]);
161
+
162
+ before(async () => {
163
+ await db.insert(addressTable).values([
164
+ {
165
+ addressId: "addr-muted",
166
+ accountConfigId: MUTED_ACCOUNT,
167
+ displayName: "Loud Marketer",
168
+ localPart: "loud",
169
+ domain: "example.com",
170
+ normalizedEmail: "loud@example.com",
171
+ normalizedCompound: "loud marketer loud@example.com",
172
+ flags: { muted: { value: true, setAt: 0 } } as never,
173
+ inboundCount: 0,
174
+ outboundCount: 0,
175
+ replyCount: 0,
176
+ lastInboundAt: 0,
177
+ lastReplyAt: 0,
178
+ createdAt: BASE_DATE,
179
+ updatedAt: BASE_DATE,
180
+ },
181
+ {
182
+ addressId: "addr-kept",
183
+ accountConfigId: MUTED_ACCOUNT,
184
+ displayName: "Colleague",
185
+ localPart: "kept",
186
+ domain: "example.com",
187
+ normalizedEmail: "kept@example.com",
188
+ normalizedCompound: "colleague kept@example.com",
189
+ flags: { muted: { value: false, setAt: 0 } } as never,
190
+ inboundCount: 0,
191
+ outboundCount: 0,
192
+ replyCount: 0,
193
+ lastInboundAt: 0,
194
+ lastReplyAt: 0,
195
+ createdAt: BASE_DATE,
196
+ updatedAt: BASE_DATE,
197
+ },
198
+ ]);
199
+
200
+ // Three from the kept sender on top, the muted sender's mail below
201
+ // them: the arrangement the old client pass could not see.
202
+ for (let index = 0; index < 3; index += 1) {
203
+ await repo.create(
204
+ makeInput({
205
+ accountConfigId: MUTED_ACCOUNT,
206
+ mailboxId: MUTED_MAILBOX,
207
+ category: "marketing",
208
+ fromEmail: "kept@example.com",
209
+ fromName: "Colleague",
210
+ subject: `kept ${index}`,
211
+ sentDate: BASE_DATE + 1000 + index,
212
+ }),
213
+ );
214
+ }
215
+ for (let index = 0; index < 4; index += 1) {
216
+ await repo.create(
217
+ makeInput({
218
+ accountConfigId: MUTED_ACCOUNT,
219
+ mailboxId: MUTED_MAILBOX,
220
+ category: "marketing",
221
+ fromEmail: "loud@example.com",
222
+ fromName: "Loud Marketer",
223
+ subject: `muted ${index}`,
224
+ sentDate: BASE_DATE + index,
225
+ }),
226
+ );
227
+ }
228
+ // No Address row at all: unknown is not muted.
229
+ await repo.create(
230
+ makeInput({
231
+ accountConfigId: MUTED_ACCOUNT,
232
+ mailboxId: MUTED_MAILBOX,
233
+ category: "marketing",
234
+ fromEmail: "stranger@example.com",
235
+ fromName: "Stranger",
236
+ subject: "stranger",
237
+ sentDate: BASE_DATE - 1,
238
+ }),
239
+ );
240
+ });
241
+
242
+ test("muted=false drops the muted sender's mail from the listing", async () => {
243
+ const page = await repo.listByDate(MUTED_ACCOUNT, {
244
+ inboxMailboxIds: MUTED_SCOPE,
245
+ search: { muted: false },
246
+ limit: 50,
247
+ });
248
+
249
+ assert.deepEqual(page.items.map((item) => item.subject).sort(), [
250
+ "kept 0",
251
+ "kept 1",
252
+ "kept 2",
253
+ "stranger",
254
+ ]);
255
+ });
256
+
257
+ // The defect: the header counted the muted sender's mail while the list
258
+ // dropped it, so "Show all" opened rows the brief would not render.
259
+ test("the count answers the same predicate as the listing", async () => {
260
+ const counted = await repo.countThreadsInScope(
261
+ MUTED_ACCOUNT,
262
+ { muted: false },
263
+ { mailboxIds: MUTED_SCOPE },
264
+ );
265
+ const wider = await repo.countThreadsInScope(
266
+ MUTED_ACCOUNT,
267
+ {},
268
+ { mailboxIds: MUTED_SCOPE },
269
+ );
270
+
271
+ assert.equal(counted, 4);
272
+ assert.equal(wider, 8);
273
+ });
274
+
275
+ test("muted=true asks for the muted sender's mail alone", async () => {
276
+ const page = await repo.listByDate(MUTED_ACCOUNT, {
277
+ inboxMailboxIds: MUTED_SCOPE,
278
+ search: { muted: true },
279
+ limit: 50,
280
+ });
281
+
282
+ assert.equal(page.items.length, 4);
283
+ assert.ok(
284
+ page.items.every((item) => item.fromEmail === "loud@example.com"),
285
+ "only the muted sender",
286
+ );
287
+ });
288
+
289
+ test("an unstated muted term filters nothing", async () => {
290
+ const page = await repo.listByDate(MUTED_ACCOUNT, {
291
+ inboxMailboxIds: MUTED_SCOPE,
292
+ search: {},
293
+ limit: 50,
294
+ });
295
+
296
+ assert.equal(page.items.length, 8);
297
+ });
298
+
299
+ test("mute composes with the other criteria", async () => {
300
+ const counted = await repo.countThreadsInScope(
301
+ MUTED_ACCOUNT,
302
+ { muted: false, category: ["marketing"] },
303
+ { mailboxIds: MUTED_SCOPE },
304
+ );
305
+
306
+ assert.equal(counted, 4);
307
+ });
308
+
309
+ // The subquery runs once per candidate row, and the generated schema's
310
+ // only address index is on `normalized_compound`, which it cannot use. A
311
+ // brief counts seven sections, so an unindexed lookup here is seven scans
312
+ // of every address the config has ever seen.
313
+ test("the muted lookup is served by an index, never a scan", async () => {
314
+ const captured: string[] = [];
315
+ const original = sqlite.prepare.bind(sqlite);
316
+ sqlite.prepare = ((source: string) => {
317
+ captured.push(source);
318
+ return original(source);
319
+ }) as typeof sqlite.prepare;
320
+ try {
321
+ await repo.countThreadsInScope(
322
+ MUTED_ACCOUNT,
323
+ { muted: false },
324
+ { mailboxIds: MUTED_SCOPE },
325
+ );
326
+ } finally {
327
+ sqlite.prepare = original as typeof sqlite.prepare;
328
+ }
329
+
330
+ const selects = captured.filter((source) => /^\s*select/i.test(source));
331
+ assert.ok(selects.length > 0, "the repo issued a select");
332
+ const plan = selects.flatMap((source) => {
333
+ const parameters = new Array((source.match(/\?/g) ?? []).length).fill(
334
+ "",
335
+ );
336
+ return (
337
+ sqlite
338
+ .prepare(`EXPLAIN QUERY PLAN ${source}`)
339
+ .all(...parameters) as Array<{ detail: string }>
340
+ ).map((row) => row.detail);
341
+ });
342
+
343
+ assert.ok(
344
+ plan.some((detail) => detail.includes("address_by_normalized_email")),
345
+ `the address lookup was not served by its index: ${plan.join(" | ")}`,
346
+ );
347
+ assert.ok(
348
+ !plan.some((detail) => /scan address/i.test(detail)),
349
+ `the address lookup fell back to a scan: ${plan.join(" | ")}`,
350
+ );
351
+ });
352
+ });
353
+ });
@@ -24,8 +24,10 @@ import type { Db } from "../db.js";
24
24
  import { NotFoundError } from "../error.js";
25
25
  import { deterministicBase36Id } from "../id.js";
26
26
  import { decodeToken } from "../pagination.js";
27
+ import { addressTable } from "../schema/i4-address.js";
27
28
  import { threadMessageTable } from "../schema/thread-message.js";
28
29
  import {
30
+ bodyMatch,
29
31
  fromMatch,
30
32
  isNarrowableTerm,
31
33
  listIdMatch,
@@ -127,10 +129,27 @@ function toItem(row: Row): ThreadMessageItem {
127
129
  // text-search seam; they live in ./thread-search-predicates.ts (the FTS5 trigram
128
130
  // index, with a folded LIKE fallback below three characters, RFC 036 D4).
129
131
 
132
+ /**
133
+ * Whether the row's From address is muted, as a correlated subquery over the
134
+ * Address table.
135
+ *
136
+ * Muting is a flag on the address rather than a column on the row, so this is
137
+ * the one criterion that reaches outside `thread_message` — the read path
138
+ * denormalizes it onto the response afterwards, which is too late to count by.
139
+ * `normalized_email` is written folded, on the same rule the fold here applies,
140
+ * so the two meet. Both sides are already scoped to one account config, and
141
+ * correlating on that rather than binding it keeps the predicate usable from
142
+ * every caller without threading the id through.
143
+ */
144
+ const mutedSender = (): SQL =>
145
+ sql`exists (select 1 from ${addressTable} where ${addressTable.accountConfigId} = ${threadMessageTable.accountConfigId} and ${addressTable.normalizedEmail} = lower(coalesce(${threadMessageTable.fromEmail}, '')) and json_extract(coalesce(nullif(${addressTable.flags}, ''), '{}'), '$.muted.value') = 1)`;
146
+
130
147
  // Translate SearchOptions into SQL conditions: subject/from/query as indexed
131
- // text predicates, the rest as plain column equalities. A multi-word `query`
132
- // matches rows where every token appears in the subject or the from fields
133
- // (AND across tokens, OR across fields) the same shape as the DynamoDB model.
148
+ // text predicates, muted as a subquery over the sender's address, the rest as
149
+ // plain column equalities. A multi-word `query`
150
+ // matches rows where every token appears in the subject, the from fields or the
151
+ // body preview (AND across tokens, OR across fields) — the same shape as the
152
+ // DynamoDB model.
134
153
  function buildSearchConditions(search: SearchOptions): SQL[] {
135
154
  const conditions: SQL[] = [];
136
155
 
@@ -140,10 +159,16 @@ function buildSearchConditions(search: SearchOptions): SQL[] {
140
159
  if (search.query) {
141
160
  const tokens = search.query.split(/\s+/).filter(Boolean);
142
161
  for (const token of tokens) {
143
- conditions.push(sql`(${subjectMatch(token)} or ${fromMatch(token)})`);
162
+ conditions.push(
163
+ sql`(${subjectMatch(token)} or ${fromMatch(token)} or ${bodyMatch(token)})`,
164
+ );
144
165
  }
145
166
  }
146
167
 
168
+ if (search.muted !== undefined) {
169
+ conditions.push(search.muted ? mutedSender() : sql`not ${mutedSender()}`);
170
+ }
171
+
147
172
  if (search.unread !== undefined) {
148
173
  conditions.push(eq(threadMessageTable.isRead, !search.unread));
149
174
  }
@@ -10,7 +10,8 @@ const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
10
10
 
11
11
  // Text search is the external-content FTS5 trigram index that
12
12
  // npm-scripts/sqlite-search-index.sql installs (RFC 036 D4): `thread_message_fts`
13
- // indexes the folded subject and sender, and MATCH is an accent- and
13
+ // indexes the folded subject, the sender, and the body preview the row carries,
14
+ // and MATCH is an accent- and
14
15
  // case-insensitive substring search (the tokenizer folds both sides, so the
15
16
  // needle is passed through untransformed). The predicate is a `rowid IN
16
17
  // (subquery)` over that index — the outer WHERE still narrows by mailbox.
@@ -53,6 +54,7 @@ const ftsRowidMatch = (matchExpr: string): SQL =>
53
54
 
54
55
  const SUBJECT_FOLDED = sql`lower(coalesce(subject, ''))`;
55
56
  const FROM_FOLDED = sql`lower(coalesce(from_name, '') || ' ' || coalesce(from_email, ''))`;
57
+ const BODY_FOLDED = sql`lower(coalesce(snippet, ''))`;
56
58
  const LIST_ID_FOLDED = sql`lower(coalesce(list_id, ''))`;
57
59
 
58
60
  const likePattern = (term: string): SQL =>
@@ -68,6 +70,22 @@ export const fromMatch = (term: string): SQL =>
68
70
  ? ftsRowidMatch(`sender : ${ftsPhrase(term)}`)
69
71
  : sql`${FROM_FOLDED} like ${likePattern(term)} escape '\\'`;
70
72
 
73
+ /**
74
+ * Match the body text the row carries: the stored preview, quoted replies
75
+ * already removed, which is the same text the list renders under the subject.
76
+ *
77
+ * The brief used to reach this text with a pass over the rows a page had
78
+ * loaded, so what a search found depended on how far the reader had scrolled
79
+ * (#1135). It is a column like the other two, so it belongs in the index and in
80
+ * the predicate. A term further into a long message is still out of reach —
81
+ * the preview is what is stored — but a term the reader can see on the row is
82
+ * now found wherever that row sits in the collection.
83
+ */
84
+ export const bodyMatch = (term: string): SQL =>
85
+ isTrigramIndexable(term)
86
+ ? ftsRowidMatch(`body : ${ftsPhrase(term)}`)
87
+ : sql`${BODY_FOLDED} like ${likePattern(term)} escape '\\'`;
88
+
71
89
  // The FTS index carries subject and sender only, so a List-Id term is always
72
90
  // the folded LIKE scan. It is the narrowing half of a rule back-apply, where a
73
91
  // scan of one config's rows beats reading them all into the service (#459).
@@ -0,0 +1,152 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { after, describe, test } from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import Database from "better-sqlite3";
8
+ import { drizzle } from "drizzle-orm/better-sqlite3";
9
+ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
10
+
11
+ /**
12
+ * A release's `schemaVersion` and an instance's `currentSchemaVersion` have to
13
+ * be the same quantity, because the self-update consent screen subtracts one
14
+ * from the other: `schemaVersion > currentSchemaVersion` is the whole of
15
+ * "installing this release runs a migration" (#279).
16
+ *
17
+ * They are computed by two different programs from two different sources. The
18
+ * manifest sums the drizzle journals (npm-scripts/lib/update-manifest.mjs); the
19
+ * wrapper counts rows in the `__drizzle_migrations_*` tables on the live
20
+ * database (deploy/vps/remit). Nothing tied the two together, and a run that
21
+ * reported ten against a manifest's nine inverted the derivation with no test
22
+ * to catch it.
23
+ *
24
+ * This applies the shipped migration sets with the shipped migrator and asserts
25
+ * the two quantities agree: one applied row per journal entry, in exactly the
26
+ * tables the wrapper counts.
27
+ */
28
+
29
+ const REPO_ROOT = new URL("../../../", import.meta.url);
30
+
31
+ const MIGRATIONS_ROOT = new URL("deploy/vps/migrations-sqlite/", REPO_ROOT);
32
+
33
+ const read = (path: string): string =>
34
+ readFileSync(new URL(path, REPO_ROOT), "utf8");
35
+
36
+ interface MigrationSet {
37
+ set: string;
38
+ table: string;
39
+ }
40
+
41
+ /**
42
+ * The sets the migrate one-shot applies, taken from the entrypoint itself
43
+ * rather than restated here: a set added, renamed or dropped there is one this
44
+ * test then migrates and counts, instead of one it silently stops covering.
45
+ * The folder is the path inside the image, where the migrations are staged at
46
+ * the working directory; in this tree they are under deploy/vps.
47
+ */
48
+ const migrationSets = (): MigrationSet[] => {
49
+ const source = read("packages/migrate/src/run-migrate.ts");
50
+ const sets = [
51
+ ...source.matchAll(
52
+ /migrationsFolder:\s*"migrations-sqlite\/(\w+)",\s*migrationsTable:\s*"(\w+)",/g,
53
+ ),
54
+ ].map(([, set, table]) => ({ set, table }));
55
+ assert.ok(
56
+ sets.length > 0,
57
+ "no sqlite migration sets found in the migrate entrypoint",
58
+ );
59
+ return sets;
60
+ };
61
+
62
+ const journalEntries = (set: string): unknown[] => {
63
+ const journal = JSON.parse(
64
+ readFileSync(new URL(`${set}/meta/_journal.json`, MIGRATIONS_ROOT), "utf8"),
65
+ ) as { entries: unknown[] };
66
+ return journal.entries;
67
+ };
68
+
69
+ /** The tables `read_schema_version` in the wrapper sums on the live database. */
70
+ const wrapperCountedTables = (): string[] => {
71
+ const match = read("deploy/vps/remit").match(
72
+ /for t in ((?:__drizzle_migrations_\w+ ?)+); do/,
73
+ );
74
+ assert.ok(match, "the wrapper no longer sums any __drizzle_migrations table");
75
+ return match[1].trim().split(/\s+/);
76
+ };
77
+
78
+ describe("schema version accounting", () => {
79
+ const dir = mkdtempSync(join(tmpdir(), "remit-schema-version-"));
80
+ const sets = migrationSets();
81
+ const sqlite = new Database(join(dir, "remit.db"));
82
+ sqlite.pragma("journal_mode = WAL");
83
+ sqlite.pragma("foreign_keys = ON");
84
+ const db = drizzle(sqlite);
85
+ for (const { set, table } of sets) {
86
+ migrate(db, {
87
+ migrationsFolder: fileURLToPath(new URL(set, MIGRATIONS_ROOT)),
88
+ migrationsTable: table,
89
+ });
90
+ }
91
+
92
+ after(() => {
93
+ sqlite.close();
94
+ rmSync(dir, { recursive: true, force: true });
95
+ });
96
+
97
+ const rows = (table: string): number =>
98
+ (
99
+ sqlite.prepare(`SELECT count(*) AS n FROM ${table}`).get() as {
100
+ n: number;
101
+ }
102
+ ).n;
103
+
104
+ test("a fully migrated database holds one row per journal entry", () => {
105
+ for (const { set, table } of sets) {
106
+ assert.equal(
107
+ rows(table),
108
+ journalEntries(set).length,
109
+ `${table} does not hold one row per entry in the ${set} journal`,
110
+ );
111
+ }
112
+ });
113
+
114
+ // The sum is the quantity both sides publish, and the manifest derives it
115
+ // from these same journals — so this is `deriveSchemaVersion` against the
116
+ // count the wrapper reads back off a migrated instance.
117
+ test("the total equals the schema version the manifest derives", () => {
118
+ const applied = sets.reduce((total, { table }) => total + rows(table), 0);
119
+ const derived = sets.reduce(
120
+ (total, { set }) => total + journalEntries(set).length,
121
+ 0,
122
+ );
123
+ assert.equal(applied, derived);
124
+ });
125
+
126
+ // Same number, same tables. A set the migrator writes and the wrapper does
127
+ // not count is a version that reads low forever, which inverts the consent
128
+ // screen's comparison rather than failing it.
129
+ test("the wrapper counts exactly the tables the migrator writes", () => {
130
+ assert.deepEqual(
131
+ wrapperCountedTables().sort(),
132
+ sets.map(({ table }) => table).sort(),
133
+ );
134
+ });
135
+
136
+ // Forward-only: the migrator is run on every boot, and a second pass that
137
+ // re-recorded an applied migration would lift the count above the journal
138
+ // sum — the shape of the drift #279 reported.
139
+ test("a second migrate run records nothing further", () => {
140
+ const before = sets.reduce((total, { table }) => total + rows(table), 0);
141
+ for (const { set, table } of sets) {
142
+ migrate(db, {
143
+ migrationsFolder: fileURLToPath(new URL(set, MIGRATIONS_ROOT)),
144
+ migrationsTable: table,
145
+ });
146
+ }
147
+ assert.equal(
148
+ sets.reduce((total, { table }) => total + rows(table), 0),
149
+ before,
150
+ );
151
+ });
152
+ });