@remit/drizzle-service 0.0.78 → 0.0.80
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
|
@@ -277,6 +277,17 @@ describe("DrizzleMessageRepository", () => {
|
|
|
277
277
|
assert.equal(rows[0].processedAt, null, "event starts undrained");
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
test("updateUid drops the source uid and keeps the folder Undo restores to", async () => {
|
|
281
|
+
// `originalUid` says the row's `uid` was recorded under
|
|
282
|
+
// `originalMailboxId`. The confirmation above makes that untrue, and a
|
|
283
|
+
// leftover that happens to equal the destination's own uid — two
|
|
284
|
+
// independent per-folder counters, so a routine collision — reads as an
|
|
285
|
+
// unsettled placement for the rest of the row's life (#1217).
|
|
286
|
+
const read = await messageRepo.get(MOVE_MESSAGE_ID);
|
|
287
|
+
assert.equal(read.originalUid, undefined);
|
|
288
|
+
assert.equal(read.originalMailboxId, SOURCE_MAILBOX_ID);
|
|
289
|
+
});
|
|
290
|
+
|
|
280
291
|
test("updateForMove throws NotFoundError for unknown messageId", async () => {
|
|
281
292
|
await assert.rejects(
|
|
282
293
|
() =>
|
package/src/repos/message.ts
CHANGED
|
@@ -574,12 +574,20 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
574
574
|
// re-index would skip them on content hash. Enqueue a move re-index event
|
|
575
575
|
// in the same transaction as the update; the search-index worker drains it
|
|
576
576
|
// with force, refreshing the stored mailbox metadata.
|
|
577
|
+
//
|
|
578
|
+
// `originalUid` goes with the settle. It exists to say the row's `uid`
|
|
579
|
+
// was recorded under `originalMailboxId`, and that stops being true here;
|
|
580
|
+
// left behind it makes the settled row indistinguishable from an
|
|
581
|
+
// in-flight one whenever the destination's counter happens to hand back
|
|
582
|
+
// the source's number, which on a young account is most of them (#1217).
|
|
583
|
+
// `originalMailboxId` stays: Undo restores the message to it.
|
|
577
584
|
const rows = await runInTransaction(this.db, async (tx) => {
|
|
578
585
|
const updated = await tx
|
|
579
586
|
.update(messageTable)
|
|
580
587
|
.set({
|
|
581
588
|
uid: newUid,
|
|
582
589
|
mailboxId: newMailboxId,
|
|
590
|
+
originalUid: null,
|
|
583
591
|
status: "active",
|
|
584
592
|
syncStatus: "synced",
|
|
585
593
|
updatedAt: Date.now(),
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import type { CreateThreadMessageInput } from "@remit/data-ports";
|
|
4
|
+
import { threadMessageTable } from "../schema/thread-message.js";
|
|
5
|
+
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
|
+
import {
|
|
7
|
+
DrizzleThreadMessageRepository,
|
|
8
|
+
THREAD_SEARCH_MAX_LIMIT,
|
|
9
|
+
} from "./thread-message.js";
|
|
10
|
+
|
|
11
|
+
// `listByFieldTerms` on sqlite (#459): the terms decide inside the query, so a
|
|
12
|
+
// rule for a sender that has been quiet for months reaches its mail however
|
|
13
|
+
// much newer mail sits above it. Filtering a date-ordered page instead answers
|
|
14
|
+
// "matches among the newest N", which is the defect this pins.
|
|
15
|
+
|
|
16
|
+
const ACCOUNT = "acct-terms";
|
|
17
|
+
const OTHER_ACCOUNT = "acct-terms-other";
|
|
18
|
+
const MAILBOX = "mbx-terms";
|
|
19
|
+
const ARCHIVE = "mbx-terms-archive";
|
|
20
|
+
|
|
21
|
+
// One more than the ceiling a back-apply reads with, so the single old match
|
|
22
|
+
// sits strictly below any window the newest page could cover.
|
|
23
|
+
const NEWER_NOISE = THREAD_SEARCH_MAX_LIMIT + 1;
|
|
24
|
+
|
|
25
|
+
const OLD_DATE = 1_600_000_000_000;
|
|
26
|
+
|
|
27
|
+
function makeInput(
|
|
28
|
+
overrides: Partial<CreateThreadMessageInput> = {},
|
|
29
|
+
): CreateThreadMessageInput {
|
|
30
|
+
return {
|
|
31
|
+
accountConfigId: ACCOUNT,
|
|
32
|
+
threadId: `t-${Math.random().toString(36).slice(2)}`,
|
|
33
|
+
messageId: `m-${Math.random().toString(36).slice(2)}`,
|
|
34
|
+
mailboxId: MAILBOX,
|
|
35
|
+
uid: 1,
|
|
36
|
+
referenceOrder: 0,
|
|
37
|
+
internalDate: OLD_DATE,
|
|
38
|
+
sentDate: OLD_DATE,
|
|
39
|
+
isRead: false,
|
|
40
|
+
isDeleted: false,
|
|
41
|
+
hasAttachment: false,
|
|
42
|
+
hasStars: false,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () => {
|
|
48
|
+
let close: () => Promise<void>;
|
|
49
|
+
let repo: DrizzleThreadMessageRepository;
|
|
50
|
+
|
|
51
|
+
before(async () => {
|
|
52
|
+
const created = await createSqliteTestDb(
|
|
53
|
+
{ threadMessage: threadMessageTable },
|
|
54
|
+
{ searchIndex: true },
|
|
55
|
+
);
|
|
56
|
+
close = created.close;
|
|
57
|
+
repo = new DrizzleThreadMessageRepository(created.db);
|
|
58
|
+
|
|
59
|
+
await repo.create(
|
|
60
|
+
makeInput({
|
|
61
|
+
messageId: "quiet-sender",
|
|
62
|
+
subject: "Your March statement",
|
|
63
|
+
fromName: "Statements",
|
|
64
|
+
fromEmail: "noreply@bank.example",
|
|
65
|
+
listId: "statements.bank.example",
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
await repo.create(
|
|
69
|
+
makeInput({
|
|
70
|
+
messageId: "accented",
|
|
71
|
+
subject: "CAFÉ closing early",
|
|
72
|
+
fromName: "Café",
|
|
73
|
+
fromEmail: "hello@paris.example",
|
|
74
|
+
sentDate: OLD_DATE + 9_000_000,
|
|
75
|
+
internalDate: OLD_DATE + 9_000_000,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
for (let index = 0; index < NEWER_NOISE; index++) {
|
|
79
|
+
await repo.create(
|
|
80
|
+
makeInput({
|
|
81
|
+
messageId: `noise-${index}`,
|
|
82
|
+
subject: `Daily digest ${index}`,
|
|
83
|
+
fromName: "Digest",
|
|
84
|
+
fromEmail: "digest@other.example",
|
|
85
|
+
sentDate: OLD_DATE + 1 + index,
|
|
86
|
+
internalDate: OLD_DATE + 1 + index,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
after(async () => {
|
|
93
|
+
await close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("finds a match older than a whole page of newer non-matching mail", async () => {
|
|
97
|
+
const result = await repo.listByFieldTerms(
|
|
98
|
+
ACCOUNT,
|
|
99
|
+
[{ field: "sender", contains: "bank.example" }],
|
|
100
|
+
{ limit: THREAD_SEARCH_MAX_LIMIT },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
result.items.map((item) => item.messageId),
|
|
105
|
+
["quiet-sender"],
|
|
106
|
+
);
|
|
107
|
+
assert.equal(result.continuationToken, undefined);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a page is a page of matches, not a page of rows", async () => {
|
|
111
|
+
const result = await repo.listByFieldTerms(
|
|
112
|
+
ACCOUNT,
|
|
113
|
+
[{ field: "sender", contains: "bank.example" }],
|
|
114
|
+
{ limit: 5 },
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
assert.equal(result.items.length, 1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("matches the subject and the List-Id columns", async () => {
|
|
121
|
+
const bySubject = await repo.listByFieldTerms(ACCOUNT, [
|
|
122
|
+
{ field: "subject", contains: "march statement" },
|
|
123
|
+
]);
|
|
124
|
+
const byListId = await repo.listByFieldTerms(ACCOUNT, [
|
|
125
|
+
{ field: "listId", contains: "statements.bank.example" },
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
assert.deepEqual(
|
|
129
|
+
bySubject.items.map((item) => item.messageId),
|
|
130
|
+
["quiet-sender"],
|
|
131
|
+
);
|
|
132
|
+
assert.deepEqual(
|
|
133
|
+
byListId.items.map((item) => item.messageId),
|
|
134
|
+
["quiet-sender"],
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("`and` requires every term, `or` any of them", async () => {
|
|
139
|
+
const terms = [
|
|
140
|
+
{ field: "sender", contains: "bank.example" },
|
|
141
|
+
{ field: "subject", contains: "daily digest 0" },
|
|
142
|
+
] as const;
|
|
143
|
+
|
|
144
|
+
const conjunction = await repo.listByFieldTerms(ACCOUNT, terms, {
|
|
145
|
+
operator: "and",
|
|
146
|
+
});
|
|
147
|
+
const disjunction = await repo.listByFieldTerms(ACCOUNT, terms, {
|
|
148
|
+
operator: "or",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
assert.deepEqual(conjunction.items, []);
|
|
152
|
+
assert.deepEqual(disjunction.items.map((item) => item.messageId).sort(), [
|
|
153
|
+
"noise-0",
|
|
154
|
+
"quiet-sender",
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Below the trigram floor the predicate is the folded LIKE, and sqlite's
|
|
159
|
+
// lower() folds ASCII only — `é` never matches a stored `CAFÉ`. Applying it
|
|
160
|
+
// anyway would drop a row the caller's own matcher accepts, which is #459
|
|
161
|
+
// again for that clause shape, so a short non-ASCII term narrows nothing.
|
|
162
|
+
test("drops a short accented term rather than missing the row it should match", async () => {
|
|
163
|
+
const result = await repo.listByFieldTerms(
|
|
164
|
+
ACCOUNT,
|
|
165
|
+
[{ field: "subject", contains: "é" }],
|
|
166
|
+
{ limit: THREAD_SEARCH_MAX_LIMIT },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
assert.ok(
|
|
170
|
+
result.items.some((item) => item.messageId === "accented"),
|
|
171
|
+
"the accented row survives",
|
|
172
|
+
);
|
|
173
|
+
assert.ok(result.items.length > 1, "the term narrowed nothing at all");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("keeps narrowing on the other terms of an `and` around a dropped one", async () => {
|
|
177
|
+
const result = await repo.listByFieldTerms(
|
|
178
|
+
ACCOUNT,
|
|
179
|
+
[
|
|
180
|
+
{ field: "subject", contains: "é" },
|
|
181
|
+
{ field: "sender", contains: "bank.example" },
|
|
182
|
+
],
|
|
183
|
+
{ operator: "and" },
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
assert.deepEqual(
|
|
187
|
+
result.items.map((item) => item.messageId),
|
|
188
|
+
["quiet-sender"],
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("drops the whole narrowing when an `or` branch cannot be evaluated", async () => {
|
|
193
|
+
const result = await repo.listByFieldTerms(
|
|
194
|
+
ACCOUNT,
|
|
195
|
+
[
|
|
196
|
+
{ field: "subject", contains: "é" },
|
|
197
|
+
{ field: "sender", contains: "bank.example" },
|
|
198
|
+
],
|
|
199
|
+
{ operator: "or", limit: 5 },
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const found = result.items.map((item) => item.messageId);
|
|
203
|
+
assert.ok(found.includes("accented"), "the dropped branch keeps its rows");
|
|
204
|
+
assert.ok(
|
|
205
|
+
found.some((messageId) => messageId.startsWith("noise-")),
|
|
206
|
+
"a narrowed `or` would have excluded these",
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("still narrows on a short ASCII term, which lower() folds correctly", async () => {
|
|
211
|
+
const result = await repo.listByFieldTerms(ACCOUNT, [
|
|
212
|
+
{ field: "sender", contains: "k." },
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
assert.deepEqual(
|
|
216
|
+
result.items.map((item) => item.messageId),
|
|
217
|
+
["quiet-sender"],
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("no terms narrows nothing", async () => {
|
|
222
|
+
const result = await repo.listByFieldTerms(ACCOUNT, [], { limit: 3 });
|
|
223
|
+
|
|
224
|
+
assert.equal(result.items.length, 3);
|
|
225
|
+
assert.ok(result.continuationToken, "more rows remain");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("stays inside the account and skips deleted rows on request", async () => {
|
|
229
|
+
await repo.create(
|
|
230
|
+
makeInput({
|
|
231
|
+
accountConfigId: OTHER_ACCOUNT,
|
|
232
|
+
messageId: "other-account",
|
|
233
|
+
fromEmail: "noreply@bank.example",
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
await repo.create(
|
|
237
|
+
makeInput({
|
|
238
|
+
messageId: "deleted-match",
|
|
239
|
+
mailboxId: ARCHIVE,
|
|
240
|
+
fromEmail: "noreply@bank.example",
|
|
241
|
+
isDeleted: true,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const result = await repo.listByFieldTerms(
|
|
246
|
+
ACCOUNT,
|
|
247
|
+
[{ field: "sender", contains: "bank.example" }],
|
|
248
|
+
{ excludeDeleted: true },
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
assert.deepEqual(
|
|
252
|
+
result.items.map((item) => item.messageId),
|
|
253
|
+
["quiet-sender"],
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("pages the matches with a keyset cursor", async () => {
|
|
258
|
+
const first = await repo.listByFieldTerms(
|
|
259
|
+
ACCOUNT,
|
|
260
|
+
[{ field: "sender", contains: "other.example" }],
|
|
261
|
+
{ limit: 2 },
|
|
262
|
+
);
|
|
263
|
+
assert.equal(first.items.length, 2);
|
|
264
|
+
assert.ok(first.continuationToken);
|
|
265
|
+
|
|
266
|
+
const second = await repo.listByFieldTerms(
|
|
267
|
+
ACCOUNT,
|
|
268
|
+
[{ field: "sender", contains: "other.example" }],
|
|
269
|
+
{ limit: 2, continuationToken: first.continuationToken },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const overlap = second.items.filter((item) =>
|
|
273
|
+
first.items.some((seen) => seen.messageId === item.messageId),
|
|
274
|
+
);
|
|
275
|
+
assert.deepEqual(overlap, [], "pages do not repeat a row");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
IThreadMessageRepository,
|
|
4
4
|
ResultList,
|
|
5
5
|
SearchOptions,
|
|
6
|
+
ThreadMessageFieldTerm,
|
|
6
7
|
ThreadMessageItem,
|
|
7
8
|
UpdateThreadMessageInput,
|
|
8
9
|
} from "@remit/data-ports";
|
|
@@ -24,7 +25,12 @@ import { NotFoundError } from "../error.js";
|
|
|
24
25
|
import { deterministicBase36Id } from "../id.js";
|
|
25
26
|
import { decodeToken } from "../pagination.js";
|
|
26
27
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
fromMatch,
|
|
30
|
+
isNarrowableTerm,
|
|
31
|
+
listIdMatch,
|
|
32
|
+
subjectMatch,
|
|
33
|
+
} from "./thread-search-predicates.js";
|
|
28
34
|
|
|
29
35
|
export const deriveThreadMessageId = (
|
|
30
36
|
threadId: string,
|
|
@@ -158,6 +164,30 @@ function buildSearchConditions(search: SearchOptions): SQL[] {
|
|
|
158
164
|
return conditions;
|
|
159
165
|
}
|
|
160
166
|
|
|
167
|
+
const fieldTermCondition = (term: ThreadMessageFieldTerm): SQL => {
|
|
168
|
+
if (term.field === "subject") return subjectMatch(term.contains);
|
|
169
|
+
if (term.field === "listId") return listIdMatch(term.contains);
|
|
170
|
+
return fromMatch(term.contains);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Combine the caller's terms into one condition, or `undefined` when there are
|
|
174
|
+
// none — an empty set narrows nothing, which is what both operators mean here.
|
|
175
|
+
//
|
|
176
|
+
// A term this engine cannot evaluate faithfully ({@link isNarrowableTerm}) is
|
|
177
|
+
// left out rather than emitted: under `and` a missing conjunct only widens the
|
|
178
|
+
// candidate set, which the caller refines anyway, but under `or` a missing
|
|
179
|
+
// branch loses matches outright, so the whole narrowing goes.
|
|
180
|
+
function buildFieldTermCondition(
|
|
181
|
+
terms: readonly ThreadMessageFieldTerm[],
|
|
182
|
+
operator: "and" | "or",
|
|
183
|
+
): SQL | undefined {
|
|
184
|
+
const narrowable = terms.filter((term) => isNarrowableTerm(term.contains));
|
|
185
|
+
if (operator === "or" && narrowable.length !== terms.length) return undefined;
|
|
186
|
+
const conditions = narrowable.map(fieldTermCondition);
|
|
187
|
+
if (conditions.length === 0) return undefined;
|
|
188
|
+
return operator === "or" ? or(...conditions) : and(...conditions);
|
|
189
|
+
}
|
|
190
|
+
|
|
161
191
|
// Keyset cursor over (sent_date, thread_message_id). `desc` walks newest→oldest,
|
|
162
192
|
// `asc` oldest→newest; the id tiebreak keeps paging stable across equal dates.
|
|
163
193
|
function sentDateCursorCond(
|
|
@@ -530,6 +560,62 @@ export class DrizzleThreadMessageRepository
|
|
|
530
560
|
};
|
|
531
561
|
}
|
|
532
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Cross-mailbox narrowing for a rule back-apply. Same keyset cursor and
|
|
565
|
+
* ordering as `searchByDate`, with the terms combined under the caller's
|
|
566
|
+
* operator instead of the AND-only `SearchOptions` shape a search box needs.
|
|
567
|
+
* The terms run in SQL over the whole config, so a page is a page of
|
|
568
|
+
* narrowed rows and a rule for a sender that has been quiet for a month
|
|
569
|
+
* reaches its mail (#459).
|
|
570
|
+
*/
|
|
571
|
+
async listByFieldTerms(
|
|
572
|
+
accountConfigId: string,
|
|
573
|
+
terms: readonly ThreadMessageFieldTerm[],
|
|
574
|
+
options?: {
|
|
575
|
+
operator?: "and" | "or";
|
|
576
|
+
order?: "asc" | "desc";
|
|
577
|
+
limit?: number;
|
|
578
|
+
continuationToken?: string;
|
|
579
|
+
excludeDeleted?: boolean;
|
|
580
|
+
},
|
|
581
|
+
): Promise<ResultList<ThreadMessageItem>> {
|
|
582
|
+
const order = options?.order ?? "desc";
|
|
583
|
+
const limit = clampThreadSearchLimit(options?.limit);
|
|
584
|
+
const cursor = options?.continuationToken
|
|
585
|
+
? decodeDateCursor(options.continuationToken)
|
|
586
|
+
: null;
|
|
587
|
+
|
|
588
|
+
const rows = await this.db
|
|
589
|
+
.select()
|
|
590
|
+
.from(threadMessageTable)
|
|
591
|
+
.where(
|
|
592
|
+
and(
|
|
593
|
+
eq(threadMessageTable.accountConfigId, accountConfigId),
|
|
594
|
+
options?.excludeDeleted
|
|
595
|
+
? eq(threadMessageTable.isDeleted, false)
|
|
596
|
+
: undefined,
|
|
597
|
+
buildFieldTermCondition(terms, options?.operator ?? "and"),
|
|
598
|
+
sentDateCursorCond(order, cursor),
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
.orderBy(
|
|
602
|
+
order === "desc"
|
|
603
|
+
? desc(threadMessageTable.sentDate)
|
|
604
|
+
: asc(threadMessageTable.sentDate),
|
|
605
|
+
asc(threadMessageTable.threadMessageId),
|
|
606
|
+
)
|
|
607
|
+
.limit(limit);
|
|
608
|
+
|
|
609
|
+
const lastRow = rows[rows.length - 1];
|
|
610
|
+
return {
|
|
611
|
+
items: rows.map(toItem),
|
|
612
|
+
continuationToken:
|
|
613
|
+
rows.length === limit && lastRow
|
|
614
|
+
? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
|
|
615
|
+
: undefined,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
533
619
|
/**
|
|
534
620
|
* COUNT of matching CONVERSATIONS over the SAME predicate as the
|
|
535
621
|
* cross-account listings, across the caller's mailbox scope.
|
|
@@ -31,11 +31,29 @@ const ftsPhrase = (term: string): string => `"${term.replace(/"/g, '""')}"`;
|
|
|
31
31
|
// index floor.
|
|
32
32
|
const isTrigramIndexable = (term: string): boolean => [...term].length >= 3;
|
|
33
33
|
|
|
34
|
+
const isAscii = (term: string): boolean =>
|
|
35
|
+
[...term].every((char) => (char.codePointAt(0) ?? 0) < 128);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a term can be pushed into the query without dropping a row the
|
|
39
|
+
* caller's own matcher would accept.
|
|
40
|
+
*
|
|
41
|
+
* Below the trigram floor the predicate is the folded LIKE, and SQLite's
|
|
42
|
+
* `lower()` folds ASCII only: `é` never matches a stored `CAFÉ`. For a search
|
|
43
|
+
* box that is the accepted C10 difference between a short term and an indexed
|
|
44
|
+
* one. For a NARROWING it is not — a narrowing that misses is the #459 defect
|
|
45
|
+
* again, one clause shape at a time — so such a term is not narrowable at all
|
|
46
|
+
* and the caller widens instead.
|
|
47
|
+
*/
|
|
48
|
+
export const isNarrowableTerm = (term: string): boolean =>
|
|
49
|
+
isTrigramIndexable(term) || isAscii(term);
|
|
50
|
+
|
|
34
51
|
const ftsRowidMatch = (matchExpr: string): SQL =>
|
|
35
52
|
sql`"thread_message"."rowid" in (select "rowid" from "thread_message_fts" where "thread_message_fts" match ${matchExpr})`;
|
|
36
53
|
|
|
37
54
|
const SUBJECT_FOLDED = sql`lower(coalesce(subject, ''))`;
|
|
38
55
|
const FROM_FOLDED = sql`lower(coalesce(from_name, '') || ' ' || coalesce(from_email, ''))`;
|
|
56
|
+
const LIST_ID_FOLDED = sql`lower(coalesce(list_id, ''))`;
|
|
39
57
|
|
|
40
58
|
const likePattern = (term: string): SQL =>
|
|
41
59
|
sql`'%' || lower(${escapeLike(term)}) || '%'`;
|
|
@@ -49,3 +67,9 @@ export const fromMatch = (term: string): SQL =>
|
|
|
49
67
|
isTrigramIndexable(term)
|
|
50
68
|
? ftsRowidMatch(`sender : ${ftsPhrase(term)}`)
|
|
51
69
|
: sql`${FROM_FOLDED} like ${likePattern(term)} escape '\\'`;
|
|
70
|
+
|
|
71
|
+
// The FTS index carries subject and sender only, so a List-Id term is always
|
|
72
|
+
// the folded LIKE scan. It is the narrowing half of a rule back-apply, where a
|
|
73
|
+
// scan of one config's rows beats reading them all into the service (#459).
|
|
74
|
+
export const listIdMatch = (term: string): SQL =>
|
|
75
|
+
sql`${LIST_ID_FOLDED} like ${likePattern(term)} escape '\\'`;
|