@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 +1 -1
- package/src/repos/search-index-shape.test.ts +82 -0
- package/src/repos/search-index-shape.ts +56 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +353 -0
- package/src/repos/thread-message.ts +29 -4
- package/src/repos/thread-search-predicates.ts +19 -1
- package/src/schema-version-accounting.sqlite.test.ts +152 -0
package/package.json
CHANGED
|
@@ -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,
|
|
132
|
-
//
|
|
133
|
-
//
|
|
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(
|
|
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
|
|
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
|
+
});
|