@remit/drizzle-service 0.0.82 → 0.0.84
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/mailbox-sync-status-backfill.sqlite.test.ts +118 -0
- package/src/repair/junk-only-address.sqlite.test.ts +7 -1
- package/src/repos/i4-mailbox.sqlite.test.ts +57 -21
- package/src/repos/i4-mailbox.test.ts +508 -13
- package/src/repos/i4-mailbox.ts +226 -24
- 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/i4-mailbox.ts +2 -0
|
@@ -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).
|
package/src/schema/i4-mailbox.ts
CHANGED
|
@@ -2,3 +2,5 @@ import * as entities from "@remit/drizzle-sqlite-schema";
|
|
|
2
2
|
|
|
3
3
|
export const mailboxTable = entities.mailboxes;
|
|
4
4
|
export const mailboxSpecialUseTable = entities.mailboxSpecialUseEntries;
|
|
5
|
+
export const mailboxAttributeTable = entities.mailboxAttributeEntries;
|
|
6
|
+
export const mailboxFlagTable = entities.mailboxFlags;
|