@remit/drizzle-service 0.0.103 → 0.0.104
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 +4 -3
- package/src/repos/account.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-default.d1.test.ts +4 -0
- package/src/repos/calendar-default.sqlite.test.ts +3 -76
- package/src/repos/calendar-default.suite.ts +68 -0
- package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-put.d1.test.ts +4 -0
- package/src/repos/calendar-put.sqlite.test.ts +3 -209
- package/src/repos/calendar-put.suite.ts +210 -0
- package/src/repos/cascade-delete.ts +1 -1
- package/src/repos/filter.d1.test.ts +4 -0
- package/src/repos/filter.suite.ts +476 -0
- package/src/repos/filter.test.ts +3 -472
- package/src/repos/i4-account-config.d1.test.ts +4 -0
- package/src/repos/i4-account-config.suite.ts +131 -0
- package/src/repos/i4-account-config.test.ts +3 -127
- package/src/repos/i4-account-export-request.d1.test.ts +4 -0
- package/src/repos/i4-account-export-request.suite.ts +44 -0
- package/src/repos/i4-account-export-request.test.ts +3 -40
- package/src/repos/i4-account-setting.d1.test.ts +4 -0
- package/src/repos/i4-account-setting.suite.ts +100 -0
- package/src/repos/i4-account-setting.test.ts +3 -93
- package/src/repos/label.conformance.d1.test.ts +20 -0
- package/src/repos/message.sqlite.test.ts +54 -0
- package/src/repos/message.ts +66 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +23 -19
- package/src/repos/thread-message-field-terms.sqlite.test.ts +9 -5
- package/src/repos/thread-message.sqlite.test.ts +6 -4
- package/src/repos/thread-message.test.ts +23 -11
- package/src/repos/thread-message.ts +13 -9
- package/src/repos/thread-search-predicates.ts +36 -63
- package/src/schema/message-data.ts +3 -0
- package/src/schema-sets/mail.ts +1 -0
- package/src/search/thread-message-search.sqlite.test.ts +49 -8
- package/src/search/thread-message-search.ts +13 -4
- package/src/test-d1.ts +40 -0
- package/src/test-db-sqlite.ts +1 -1
- package/src/test-shipped-sqlite-schema.ts +8 -6
- package/src/test-stores.ts +30 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
3
|
import type { CreateThreadMessageInput } from "@remit/data-ports";
|
|
4
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
4
5
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
5
6
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
6
7
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
@@ -51,7 +52,10 @@ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () =>
|
|
|
51
52
|
|
|
52
53
|
before(async () => {
|
|
53
54
|
const created = await createSqliteTestDb(
|
|
54
|
-
{
|
|
55
|
+
{
|
|
56
|
+
threadMessage: threadMessageTable,
|
|
57
|
+
messageSearchText: messageSearchTextTable,
|
|
58
|
+
},
|
|
55
59
|
{ searchIndex: true },
|
|
56
60
|
);
|
|
57
61
|
close = created.close;
|
|
@@ -160,10 +164,10 @@ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () =>
|
|
|
160
164
|
]);
|
|
161
165
|
});
|
|
162
166
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
+
// A field term is the folded LIKE, and sqlite's lower() folds ASCII only —
|
|
168
|
+
// `é` never matches a stored `CAFÉ`. Applying it anyway would drop a row
|
|
169
|
+
// the caller's own matcher accepts, which is #459 again for that clause
|
|
170
|
+
// shape, so a non-ASCII term narrows nothing.
|
|
167
171
|
test("drops a short accented term rather than missing the row it should match", async () => {
|
|
168
172
|
const result = await repo.listByFieldTerms(
|
|
169
173
|
ACCOUNT,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
3
|
import type { CreateThreadMessageInput } from "@remit/data-ports";
|
|
4
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
4
5
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
5
6
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
6
7
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
@@ -17,9 +18,9 @@ import {
|
|
|
17
18
|
} from "./thread-message.js";
|
|
18
19
|
|
|
19
20
|
// The thread-message repo on sqlite (RFC 036 D1): CRUD, keyset pagination, and
|
|
20
|
-
// text search — the FTS5
|
|
21
|
-
//
|
|
22
|
-
// and case-insensitive
|
|
21
|
+
// text search — the FTS5 unicode61 index (D4), matching words by prefix, and
|
|
22
|
+
// the folded-LIKE scan for terms with no word. The search contract is accent-
|
|
23
|
+
// and case-insensitive word-prefix match over subject and sender.
|
|
23
24
|
|
|
24
25
|
const ACCOUNT = "acct-thread";
|
|
25
26
|
const MAILBOX = "mbx-thread";
|
|
@@ -55,6 +56,7 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
55
56
|
({ db, sqlite, close } = await createSqliteTestDb(
|
|
56
57
|
{
|
|
57
58
|
threadMessage: threadMessageTable,
|
|
59
|
+
messageSearchText: messageSearchTextTable,
|
|
58
60
|
},
|
|
59
61
|
{ searchIndex: true },
|
|
60
62
|
));
|
|
@@ -115,7 +117,7 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
115
117
|
assert.ok(byName.items.some((i) => i.fromName === "Alice Kramer"));
|
|
116
118
|
});
|
|
117
119
|
|
|
118
|
-
test("FTS
|
|
120
|
+
test("FTS search folds diacritics both ways", async () => {
|
|
119
121
|
await repo.create(makeInput({ subject: "Réunion budget" }));
|
|
120
122
|
const folded = await repo.searchByMailbox(
|
|
121
123
|
ACCOUNT,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
CreateThreadMessageInput,
|
|
5
|
+
IMessageRepository,
|
|
6
|
+
} from "@remit/data-ports";
|
|
4
7
|
import shortUuid from "short-uuid";
|
|
8
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
5
9
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
6
10
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
7
11
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
@@ -40,11 +44,14 @@ function makeInput(
|
|
|
40
44
|
};
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
// A real better-sqlite3 database with the FTS5
|
|
47
|
+
// A real better-sqlite3 database with the FTS5 objects the migrator
|
|
44
48
|
// installs, so the search predicates run their shipped path.
|
|
45
49
|
const setupDb = () =>
|
|
46
50
|
createSqliteTestDb(
|
|
47
|
-
{
|
|
51
|
+
{
|
|
52
|
+
threadMessage: threadMessageTable,
|
|
53
|
+
messageSearchText: messageSearchTextTable,
|
|
54
|
+
},
|
|
48
55
|
{ searchIndex: true },
|
|
49
56
|
);
|
|
50
57
|
|
|
@@ -194,7 +201,7 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
194
201
|
});
|
|
195
202
|
|
|
196
203
|
// ── Scenario 4 ────────────────────────────────────────────────────────────
|
|
197
|
-
// Matching runs over the WHOLE mailbox via the
|
|
204
|
+
// Matching runs over the WHOLE mailbox via the full-text index, so an old
|
|
198
205
|
// match well behind the recent rows is still found and paged (the DynamoDB
|
|
199
206
|
// recent-window bound does not apply here — the improvement over #443 on the
|
|
200
207
|
// DDB path).
|
|
@@ -386,17 +393,20 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
386
393
|
|
|
387
394
|
describe("DrizzleThreadMessageRepository.searchByDate", () => {
|
|
388
395
|
let repo: DrizzleThreadMessageRepository;
|
|
396
|
+
let messages: IMessageRepository;
|
|
389
397
|
let close: () => Promise<void>;
|
|
390
398
|
const cleanup: Array<() => Promise<void>> = [];
|
|
391
399
|
|
|
392
400
|
before(async () => {
|
|
393
401
|
const harness = await setupDb();
|
|
394
402
|
close = harness.close;
|
|
395
|
-
|
|
403
|
+
const repositories = createSqliteRepositories({
|
|
396
404
|
app: harness.db,
|
|
397
405
|
mail: harness.db,
|
|
398
406
|
search: harness.db,
|
|
399
|
-
})
|
|
407
|
+
});
|
|
408
|
+
repo = repositories.threadMessage;
|
|
409
|
+
messages = repositories.message;
|
|
400
410
|
});
|
|
401
411
|
|
|
402
412
|
after(async () => {
|
|
@@ -412,9 +422,11 @@ describe("DrizzleThreadMessageRepository.searchByDate", () => {
|
|
|
412
422
|
rows: Array<Partial<CreateThreadMessageInput>>,
|
|
413
423
|
): Promise<void> {
|
|
414
424
|
for (const overrides of rows) {
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
425
|
+
const input = makeInput(accountConfigId, mailboxId, overrides);
|
|
426
|
+
if (overrides.snippet) {
|
|
427
|
+
await messages.setSearchText(input.messageId, overrides.snippet);
|
|
428
|
+
}
|
|
429
|
+
const created = await repo.create(input);
|
|
418
430
|
cleanup.push(() => repo.delete(accountConfigId, created.threadMessageId));
|
|
419
431
|
}
|
|
420
432
|
}
|
|
@@ -1053,7 +1065,7 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
|
|
|
1053
1065
|
assert.deepEqual(subjectsOf(res), ["shared token here"]);
|
|
1054
1066
|
});
|
|
1055
1067
|
|
|
1056
|
-
test("LIKE metacharacters in
|
|
1068
|
+
test("LIKE metacharacters in a query with no word match literally", async () => {
|
|
1057
1069
|
const acct = uuid();
|
|
1058
1070
|
const mbx = uuid();
|
|
1059
1071
|
await seed(acct, mbx, [
|
|
@@ -1061,7 +1073,7 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
|
|
|
1061
1073
|
{ subject: "50 percent off" },
|
|
1062
1074
|
]);
|
|
1063
1075
|
|
|
1064
|
-
const res = await repo.searchByMailboxWindow(acct, mbx, { query: "
|
|
1076
|
+
const res = await repo.searchByMailboxWindow(acct, mbx, { query: "%" });
|
|
1065
1077
|
assert.deepEqual(subjectsOf(res), ["50% off today"]);
|
|
1066
1078
|
});
|
|
1067
1079
|
|
|
@@ -37,10 +37,12 @@ import type {
|
|
|
37
37
|
import { inList } from "./in-list.js";
|
|
38
38
|
import {
|
|
39
39
|
bodyMatch,
|
|
40
|
+
fromContains,
|
|
40
41
|
fromMatch,
|
|
41
42
|
isNarrowableTerm,
|
|
42
43
|
listIdMatch,
|
|
43
44
|
senderMatchRank,
|
|
45
|
+
subjectContains,
|
|
44
46
|
subjectMatch,
|
|
45
47
|
} from "./thread-search-predicates.js";
|
|
46
48
|
|
|
@@ -165,9 +167,10 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
165
167
|
|
|
166
168
|
// ─── Search predicates ────────────────────────────────────────────────────────
|
|
167
169
|
|
|
168
|
-
// The accent-/case-insensitive
|
|
169
|
-
// text-search seam; they live in ./thread-search-predicates.ts
|
|
170
|
-
// index, with a folded LIKE
|
|
170
|
+
// The accent-/case-insensitive word-prefix predicates are the one
|
|
171
|
+
// engine-specific text-search seam; they live in ./thread-search-predicates.ts
|
|
172
|
+
// (the FTS5 unicode61 index, with a folded LIKE scan for substring field terms,
|
|
173
|
+
// RFC 036 D4).
|
|
171
174
|
|
|
172
175
|
/**
|
|
173
176
|
* Whether the row's From address is muted, as a correlated subquery over the
|
|
@@ -184,8 +187,9 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
184
187
|
const mutedSender = (): SQL =>
|
|
185
188
|
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)`;
|
|
186
189
|
|
|
187
|
-
// The columns the search index is built from
|
|
188
|
-
//
|
|
190
|
+
// The columns the search index is built from, and the snippet, which body sync
|
|
191
|
+
// writes after the body's search text; an update that sets none of them leaves
|
|
192
|
+
// the index as it is.
|
|
189
193
|
const touchesSearchIndex = (input: UpdateThreadMessageInput): boolean =>
|
|
190
194
|
input.mailboxId !== undefined ||
|
|
191
195
|
input.sentDate !== undefined ||
|
|
@@ -245,9 +249,9 @@ function buildSearchConditions(search: SearchOptions): SearchConditions {
|
|
|
245
249
|
}
|
|
246
250
|
|
|
247
251
|
const fieldTermCondition = (term: ThreadMessageFieldTerm): SQL => {
|
|
248
|
-
if (term.field === "subject") return
|
|
252
|
+
if (term.field === "subject") return subjectContains(term.contains);
|
|
249
253
|
if (term.field === "listId") return listIdMatch(term.contains);
|
|
250
|
-
return
|
|
254
|
+
return fromContains(term.contains);
|
|
251
255
|
};
|
|
252
256
|
|
|
253
257
|
// Combine the caller's terms into one condition, or `undefined` when there are
|
|
@@ -1261,8 +1265,8 @@ export class DrizzleThreadMessageRepository
|
|
|
1261
1265
|
|
|
1262
1266
|
/**
|
|
1263
1267
|
* Mailbox search for `searchThreads`. Matching (subject / from / query and
|
|
1264
|
-
* the boolean filters) runs in SQL over the whole mailbox via the
|
|
1265
|
-
*
|
|
1268
|
+
* the boolean filters) runs in SQL over the whole mailbox via the full-text
|
|
1269
|
+
* index, ordered by sent_date with an id tiebreak. `limit` is a page size
|
|
1266
1270
|
* over MATCHES (clamped to THREAD_SEARCH_MAX_LIMIT); the DynamoDB name is
|
|
1267
1271
|
* kept for interface parity, but there is no recent-window read bound — the
|
|
1268
1272
|
* text is indexed, so a match anywhere in the mailbox is reachable.
|
|
@@ -1,55 +1,39 @@
|
|
|
1
1
|
import { type SQL, type SQLWrapper, sql } from "drizzle-orm";
|
|
2
2
|
import { threadMessageSearchTable as fts } from "../search/thread-message-search.js";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Every predicate here is a condition over `thread_message_fts`, the FTS5 index
|
|
5
|
+
// in the tenant search database (#1420), tokenized `unicode61
|
|
6
|
+
// remove_diacritics 2`: it indexes words, folding case and diacritics. A
|
|
7
|
+
// search term matches words that start with it, so "matt" finds "Matthijs",
|
|
8
|
+
// and a term the tokenizer splits ("ada@example") matches those words in a
|
|
9
|
+
// row. The predicate is a `rowid IN (subquery)` over the same table, so terms
|
|
10
|
+
// combine with AND and OR like any other condition.
|
|
11
|
+
//
|
|
12
|
+
// A term with no letter or digit has no word to match and falls back to a
|
|
13
|
+
// folded LIKE scan of the index's own copy of the text, as do the field-term
|
|
14
|
+
// narrowings, whose contract is substring: lower() both sides,
|
|
15
|
+
// substring-match, `escape '\'` making the JS-escaped metacharacters literal.
|
|
16
|
+
// The scan folds ASCII case only (contract C10).
|
|
9
17
|
|
|
10
18
|
const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
|
|
11
19
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// like any other condition.
|
|
19
|
-
//
|
|
20
|
-
// Trigram indexing needs three characters, so a one- or two-character term
|
|
21
|
-
// falls back to a folded LIKE scan of the index's own copy of the text —
|
|
22
|
-
// lower() both sides, substring-match, `escape '\'` making the JS-escaped
|
|
23
|
-
// metacharacters literal. It is case-insensitive for ASCII and does not fold
|
|
24
|
-
// diacritics; the accepted difference between a short term and an indexed one
|
|
25
|
-
// (contract C10).
|
|
26
|
-
|
|
27
|
-
// FTS5 treats bare query text as its match grammar (AND/OR/NEAR/`*`/`-`/`:`), so
|
|
28
|
-
// wrap the term as a double-quoted string literal — doubling embedded quotes —
|
|
29
|
-
// to match it verbatim as a trigram substring.
|
|
30
|
-
const ftsPhrase = (term: string): string => `"${term.replace(/"/g, '""')}"`;
|
|
31
|
-
|
|
32
|
-
// Trigram tokenization is by character, so measure the term in code points, not
|
|
33
|
-
// UTF-16 units — a two-astral-character term is still under the three-char
|
|
34
|
-
// index floor.
|
|
35
|
-
const isTrigramIndexable = (term: string): boolean => [...term].length >= 3;
|
|
20
|
+
// Quoted, so the term is tokenized as a phrase rather than read as FTS5 query
|
|
21
|
+
// grammar; the trailing `*` makes its last word a prefix.
|
|
22
|
+
const ftsPrefixPhrase = (term: string): string =>
|
|
23
|
+
`"${term.replace(/"/g, '""')}" *`;
|
|
24
|
+
|
|
25
|
+
const hasWord = (term: string): boolean => /[\p{L}\p{N}]/u.test(term);
|
|
36
26
|
|
|
37
27
|
const isAscii = (term: string): boolean =>
|
|
38
28
|
[...term].every((char) => (char.codePointAt(0) ?? 0) < 128);
|
|
39
29
|
|
|
40
30
|
/**
|
|
41
|
-
* Whether a term can be pushed into the query without dropping a row the
|
|
42
|
-
* caller's own matcher would accept.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `lower()` folds ASCII only: `é` never matches a stored `CAFÉ`. For a search
|
|
46
|
-
* box that is the accepted C10 difference between a short term and an indexed
|
|
47
|
-
* one. For a NARROWING it is not — a narrowing that misses is the #459 defect
|
|
48
|
-
* again, one clause shape at a time — so such a term is not narrowable at all
|
|
49
|
-
* and the caller widens instead.
|
|
31
|
+
* Whether a field term can be pushed into the query without dropping a row the
|
|
32
|
+
* caller's own matcher would accept. The narrowing is a substring LIKE, and
|
|
33
|
+
* SQLite's `lower()` folds ASCII only: `é` never matches a stored `CAFÉ`, so a
|
|
34
|
+
* non-ASCII term is not narrowable and the caller widens instead (#459).
|
|
50
35
|
*/
|
|
51
|
-
export const isNarrowableTerm = (term: string): boolean =>
|
|
52
|
-
isTrigramIndexable(term) || isAscii(term);
|
|
36
|
+
export const isNarrowableTerm = (term: string): boolean => isAscii(term);
|
|
53
37
|
|
|
54
38
|
const ftsRowidMatch = (matchExpr: string): SQL =>
|
|
55
39
|
sql`${fts}.rowid in (select rowid from ${fts} where ${fts} match ${matchExpr})`;
|
|
@@ -60,31 +44,20 @@ const likePattern = (term: string): SQL =>
|
|
|
60
44
|
const folded = (column: SQLWrapper, term: string): SQL =>
|
|
61
45
|
sql`lower(${column}) like ${likePattern(term)} escape '\\'`;
|
|
62
46
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
? ftsRowidMatch(
|
|
66
|
-
: folded(fts
|
|
47
|
+
const wordMatch = (column: "subject" | "sender" | "body", term: string): SQL =>
|
|
48
|
+
hasWord(term)
|
|
49
|
+
? ftsRowidMatch(`${column} : ${ftsPrefixPhrase(term)}`)
|
|
50
|
+
: folded(fts[column], term);
|
|
67
51
|
|
|
68
|
-
export const
|
|
69
|
-
isTrigramIndexable(term)
|
|
70
|
-
? ftsRowidMatch(`sender : ${ftsPhrase(term)}`)
|
|
71
|
-
: folded(fts.sender, term);
|
|
52
|
+
export const subjectMatch = (term: string): SQL => wordMatch("subject", term);
|
|
72
53
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
: folded(fts.body, term);
|
|
54
|
+
export const fromMatch = (term: string): SQL => wordMatch("sender", term);
|
|
55
|
+
|
|
56
|
+
export const bodyMatch = (term: string): SQL => wordMatch("body", term);
|
|
57
|
+
|
|
58
|
+
export const subjectContains = (term: string): SQL => folded(fts.subject, term);
|
|
59
|
+
|
|
60
|
+
export const fromContains = (term: string): SQL => folded(fts.sender, term);
|
|
88
61
|
|
|
89
62
|
// The List-Id is carried unindexed, so a List-Id term is always the folded LIKE
|
|
90
63
|
// scan. It is the narrowing half of a rule back-apply (#459).
|
|
@@ -8,6 +8,7 @@ const envelopeAddressTable = entities.envelopeAddresses;
|
|
|
8
8
|
const envelopeTable = entities.envelopes;
|
|
9
9
|
const messageFlagTable = entities.messageFlags;
|
|
10
10
|
const messageReferenceTable = entities.messageReferences;
|
|
11
|
+
const messageSearchTextTable = entities.messageSearchTexts;
|
|
11
12
|
const messageTable = entities.messageRecords;
|
|
12
13
|
const rawMessageStorageTable = entities.rawMessageStorages;
|
|
13
14
|
|
|
@@ -20,6 +21,7 @@ export {
|
|
|
20
21
|
envelopeTable,
|
|
21
22
|
messageFlagTable,
|
|
22
23
|
messageReferenceTable,
|
|
24
|
+
messageSearchTextTable,
|
|
23
25
|
messageTable,
|
|
24
26
|
rawMessageStorageTable,
|
|
25
27
|
};
|
|
@@ -35,6 +37,7 @@ export const messageDataSchema = {
|
|
|
35
37
|
bodyStructureRefetch: bodyStructureRefetchTable,
|
|
36
38
|
message: messageTable,
|
|
37
39
|
messageFlag: messageFlagTable,
|
|
40
|
+
messageSearchText: messageSearchTextTable,
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export type MessageDataSchema = typeof messageDataSchema;
|
package/src/schema-sets/mail.ts
CHANGED
|
@@ -5,6 +5,7 @@ import Database from "better-sqlite3";
|
|
|
5
5
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
6
6
|
import { syncStore } from "../databases.js";
|
|
7
7
|
import type { Db } from "../db.js";
|
|
8
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
8
9
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
9
10
|
import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
|
|
10
11
|
import {
|
|
@@ -88,14 +89,18 @@ describe("the thread-message search index in its own database", () => {
|
|
|
88
89
|
});
|
|
89
90
|
|
|
90
91
|
test("finds a message by subject, sender and body once it is written", async () => {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
const message = input({
|
|
93
|
+
subject: "Quarterly invoice",
|
|
94
|
+
fromName: "Ada Lovelace",
|
|
95
|
+
fromEmail: "ada@example.test",
|
|
96
|
+
});
|
|
97
|
+
await mail.db.insert(messageSearchTextTable).values({
|
|
98
|
+
messageId: message.messageId,
|
|
99
|
+
text: "the analytical engine",
|
|
100
|
+
createdAt: 0,
|
|
101
|
+
updatedAt: 0,
|
|
102
|
+
});
|
|
103
|
+
const created = await repo.create(message);
|
|
99
104
|
|
|
100
105
|
assert.deepEqual(await found("invoice"), [created.threadMessageId]);
|
|
101
106
|
assert.deepEqual(await found("lovelace"), [created.threadMessageId]);
|
|
@@ -103,6 +108,19 @@ describe("the thread-message search index in its own database", () => {
|
|
|
103
108
|
assert.deepEqual(await found("nowhere"), []);
|
|
104
109
|
});
|
|
105
110
|
|
|
111
|
+
test("matches words by prefix, folding case and diacritics", async () => {
|
|
112
|
+
const created = await repo.create(
|
|
113
|
+
input({
|
|
114
|
+
subject: "Crème brûlée recipe",
|
|
115
|
+
fromName: "Matthijs van Henten",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(await found("matt"), [created.threadMessageId]);
|
|
120
|
+
assert.deepEqual(await found("CREME brul"), [created.threadMessageId]);
|
|
121
|
+
assert.deepEqual(await found("thijs"), []);
|
|
122
|
+
});
|
|
123
|
+
|
|
106
124
|
test("follows a rewritten subject and drops the old one", async () => {
|
|
107
125
|
const created = await repo.create(input({ subject: "Draft agenda" }));
|
|
108
126
|
await repo.update(ACCOUNT, created.threadMessageId, {
|
|
@@ -133,4 +151,27 @@ describe("the thread-message search index in its own database", () => {
|
|
|
133
151
|
assert.equal(await index.needsRebuild(), false);
|
|
134
152
|
assert.deepEqual(await found("receipt"), [created.threadMessageId]);
|
|
135
153
|
});
|
|
154
|
+
|
|
155
|
+
test("matches the body text past the snippet, through a rewrite and a rebuild", async () => {
|
|
156
|
+
const created = await repo.create(
|
|
157
|
+
input({ subject: "Long letter", snippet: "Dear reader, a preview" }),
|
|
158
|
+
);
|
|
159
|
+
const text = `Dear reader, a preview ${"filler words ".repeat(200)}zygomorphic`;
|
|
160
|
+
await mail.db.insert(messageSearchTextTable).values({
|
|
161
|
+
messageId: created.messageId,
|
|
162
|
+
text,
|
|
163
|
+
createdAt: 0,
|
|
164
|
+
updatedAt: 0,
|
|
165
|
+
});
|
|
166
|
+
await index.sync([created.threadMessageId]);
|
|
167
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
168
|
+
|
|
169
|
+
await repo.update(ACCOUNT, created.threadMessageId, {
|
|
170
|
+
subject: "Longer letter",
|
|
171
|
+
});
|
|
172
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
173
|
+
|
|
174
|
+
await index.rebuild();
|
|
175
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
176
|
+
});
|
|
136
177
|
});
|
|
@@ -14,9 +14,10 @@ import {
|
|
|
14
14
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
15
15
|
import type { SqliteBatchItem, WriteBatch } from "../batch.js";
|
|
16
16
|
import type { RepositoryDb, SqliteStore } from "../db.js";
|
|
17
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
17
18
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
18
19
|
|
|
19
|
-
// The FTS5
|
|
20
|
+
// The FTS5 table the search migration set creates
|
|
20
21
|
// (deploy/vps/migrations-sqlite/search). drizzle-kit cannot express a virtual
|
|
21
22
|
// table, so its DDL is that committed migration and this declaration only lets
|
|
22
23
|
// drizzle build statements against it; no drizzle-kit config reads it.
|
|
@@ -68,7 +69,7 @@ const indexedColumns = {
|
|
|
68
69
|
subject: threadMessageTable.subject,
|
|
69
70
|
fromName: threadMessageTable.fromName,
|
|
70
71
|
fromEmail: threadMessageTable.fromEmail,
|
|
71
|
-
|
|
72
|
+
searchText: messageSearchTextTable.text,
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
interface IndexedRow {
|
|
@@ -80,7 +81,7 @@ interface IndexedRow {
|
|
|
80
81
|
subject: string | null;
|
|
81
82
|
fromName: string | null;
|
|
82
83
|
fromEmail: string | null;
|
|
83
|
-
|
|
84
|
+
searchText: string | null;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const toDocument = (row: IndexedRow): SearchDocument => ({
|
|
@@ -92,7 +93,7 @@ const toDocument = (row: IndexedRow): SearchDocument => ({
|
|
|
92
93
|
listId: row.listId ?? "",
|
|
93
94
|
subject: row.subject ?? "",
|
|
94
95
|
sender: `${row.fromName ?? ""} ${row.fromEmail ?? ""}`,
|
|
95
|
-
body: row.
|
|
96
|
+
body: row.searchText ?? "",
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
const chunk = <T>(items: readonly T[], size: number): T[][] => {
|
|
@@ -156,6 +157,10 @@ export class SearchIndex {
|
|
|
156
157
|
const rows = await this.mail
|
|
157
158
|
.select(indexedColumns)
|
|
158
159
|
.from(threadMessageTable)
|
|
160
|
+
.leftJoin(
|
|
161
|
+
messageSearchTextTable,
|
|
162
|
+
eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
|
|
163
|
+
)
|
|
159
164
|
.where(inArray(threadMessageTable.threadMessageId, slice));
|
|
160
165
|
const items: SqliteBatchItem[] = [
|
|
161
166
|
this.search
|
|
@@ -257,6 +262,10 @@ export class SearchIndex {
|
|
|
257
262
|
const rows = await this.mail
|
|
258
263
|
.select(indexedColumns)
|
|
259
264
|
.from(threadMessageTable)
|
|
265
|
+
.leftJoin(
|
|
266
|
+
messageSearchTextTable,
|
|
267
|
+
eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
|
|
268
|
+
)
|
|
260
269
|
.where(gt(threadMessageTable.threadMessageId, after))
|
|
261
270
|
.orderBy(asc(threadMessageTable.threadMessageId))
|
|
262
271
|
.limit(DOCUMENTS_PER_REBUILD_PAGE);
|
package/src/test-d1.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
2
|
+
import { Miniflare } from "miniflare";
|
|
3
|
+
import { asyncBatch } from "./batch.js";
|
|
4
|
+
import { syncStore } from "./databases.js";
|
|
5
|
+
import { migrationStatements } from "./test-shipped-sqlite-schema.js";
|
|
6
|
+
import { createShippedSqliteDb } from "./test-sqlite.js";
|
|
7
|
+
import type { OpenTestStores } from "./test-stores.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The stores a Cloudflare deployment runs the app repositories on: the app
|
|
11
|
+
* database on an in-process D1, migrated with the committed app set, written
|
|
12
|
+
* through `db.batch()`. The mail and search sets stay on a synchronous handle,
|
|
13
|
+
* as they do in the tenant mail Durable Object.
|
|
14
|
+
*/
|
|
15
|
+
export const openD1TestStores: OpenTestStores = async () => {
|
|
16
|
+
const miniflare = new Miniflare({
|
|
17
|
+
modules: true,
|
|
18
|
+
script: "export default {};",
|
|
19
|
+
d1Databases: { APP: "app" },
|
|
20
|
+
});
|
|
21
|
+
const d1 = await miniflare.getD1Database("APP");
|
|
22
|
+
const statements = migrationStatements("app").filter(
|
|
23
|
+
(statement) => statement.trim() !== "",
|
|
24
|
+
);
|
|
25
|
+
await d1.batch(statements.map((statement) => d1.prepare(statement)));
|
|
26
|
+
const app = drizzle(d1);
|
|
27
|
+
const shipped = createShippedSqliteDb();
|
|
28
|
+
const local = syncStore(shipped.db);
|
|
29
|
+
return {
|
|
30
|
+
stores: {
|
|
31
|
+
app: { db: app, batch: asyncBatch(app) },
|
|
32
|
+
mail: local,
|
|
33
|
+
search: local,
|
|
34
|
+
},
|
|
35
|
+
close: async () => {
|
|
36
|
+
shipped.close();
|
|
37
|
+
await miniflare.dispose();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
package/src/test-db-sqlite.ts
CHANGED
|
@@ -46,7 +46,7 @@ export async function createSqliteTestDb<
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Install the committed search set beside the pushed schema so the
|
|
49
|
-
// thread-message search predicates run their real
|
|
49
|
+
// thread-message search predicates run their real FTS5 path. The one
|
|
50
50
|
// handle then plays both the mail and the search database.
|
|
51
51
|
if (options?.searchIndex) {
|
|
52
52
|
applyMigrationSet(sqlite, "search");
|
|
@@ -61,16 +61,18 @@ export const shippedIndexDdl = (index: string): string => {
|
|
|
61
61
|
return statement;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/** Every statement of a set's committed migrations, in the order the migrator runs them. */
|
|
65
|
+
export const migrationStatements = (set: MigrationSet): string[] =>
|
|
66
|
+
migrationJournal(set).flatMap((entry) =>
|
|
67
|
+
migrationSql(set, entry.tag).split("--> statement-breakpoint"),
|
|
68
|
+
);
|
|
69
|
+
|
|
64
70
|
/** Run every committed migration of a set against an open database. */
|
|
65
71
|
export const applyMigrationSet = (
|
|
66
72
|
sqlite: Database.Database,
|
|
67
73
|
set: MigrationSet,
|
|
68
74
|
): void => {
|
|
69
|
-
for (const
|
|
70
|
-
|
|
71
|
-
"--> statement-breakpoint",
|
|
72
|
-
)) {
|
|
73
|
-
sqlite.exec(statement);
|
|
74
|
-
}
|
|
75
|
+
for (const statement of migrationStatements(set)) {
|
|
76
|
+
sqlite.exec(statement);
|
|
75
77
|
}
|
|
76
78
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type SqliteStores, syncStores } from "./databases.js";
|
|
2
|
+
import {
|
|
3
|
+
calendarEventIndexTable,
|
|
4
|
+
calendarObjectTable,
|
|
5
|
+
calendarTable,
|
|
6
|
+
} from "./schema.js";
|
|
7
|
+
import { createTestDb } from "./test-db.js";
|
|
8
|
+
import { createSqliteTestDb } from "./test-db-sqlite.js";
|
|
9
|
+
|
|
10
|
+
/** The stores a repository suite runs against, opened once per test file. */
|
|
11
|
+
export type OpenTestStores = () => Promise<{
|
|
12
|
+
stores: SqliteStores;
|
|
13
|
+
close: () => Promise<void>;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
/** The whole schema on one in-memory better-sqlite3 handle that plays all three databases. */
|
|
17
|
+
export const openSqliteTestStores: OpenTestStores = async () => {
|
|
18
|
+
const { db, close } = await createTestDb();
|
|
19
|
+
return { stores: syncStores({ app: db, mail: db, search: db }), close };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The three calendar tables alone on one in-memory better-sqlite3 handle. */
|
|
23
|
+
export const openCalendarSqliteTestStores: OpenTestStores = async () => {
|
|
24
|
+
const { db, close } = await createSqliteTestDb({
|
|
25
|
+
calendars: calendarTable,
|
|
26
|
+
calendarObjects: calendarObjectTable,
|
|
27
|
+
calendarEventIndexes: calendarEventIndexTable,
|
|
28
|
+
});
|
|
29
|
+
return { stores: syncStores({ app: db, mail: db, search: db }), close };
|
|
30
|
+
};
|