@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.
Files changed (42) hide show
  1. package/package.json +4 -3
  2. package/src/repos/account.conformance.d1.test.ts +20 -0
  3. package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
  4. package/src/repos/calendar-default.d1.test.ts +4 -0
  5. package/src/repos/calendar-default.sqlite.test.ts +3 -76
  6. package/src/repos/calendar-default.suite.ts +68 -0
  7. package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
  8. package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
  9. package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
  10. package/src/repos/calendar-put.d1.test.ts +4 -0
  11. package/src/repos/calendar-put.sqlite.test.ts +3 -209
  12. package/src/repos/calendar-put.suite.ts +210 -0
  13. package/src/repos/cascade-delete.ts +1 -1
  14. package/src/repos/filter.d1.test.ts +4 -0
  15. package/src/repos/filter.suite.ts +476 -0
  16. package/src/repos/filter.test.ts +3 -472
  17. package/src/repos/i4-account-config.d1.test.ts +4 -0
  18. package/src/repos/i4-account-config.suite.ts +131 -0
  19. package/src/repos/i4-account-config.test.ts +3 -127
  20. package/src/repos/i4-account-export-request.d1.test.ts +4 -0
  21. package/src/repos/i4-account-export-request.suite.ts +44 -0
  22. package/src/repos/i4-account-export-request.test.ts +3 -40
  23. package/src/repos/i4-account-setting.d1.test.ts +4 -0
  24. package/src/repos/i4-account-setting.suite.ts +100 -0
  25. package/src/repos/i4-account-setting.test.ts +3 -93
  26. package/src/repos/label.conformance.d1.test.ts +20 -0
  27. package/src/repos/message.sqlite.test.ts +54 -0
  28. package/src/repos/message.ts +66 -0
  29. package/src/repos/thread-message-body-muted.sqlite.test.ts +23 -19
  30. package/src/repos/thread-message-field-terms.sqlite.test.ts +9 -5
  31. package/src/repos/thread-message.sqlite.test.ts +6 -4
  32. package/src/repos/thread-message.test.ts +23 -11
  33. package/src/repos/thread-message.ts +13 -9
  34. package/src/repos/thread-search-predicates.ts +36 -63
  35. package/src/schema/message-data.ts +3 -0
  36. package/src/schema-sets/mail.ts +1 -0
  37. package/src/search/thread-message-search.sqlite.test.ts +49 -8
  38. package/src/search/thread-message-search.ts +13 -4
  39. package/src/test-d1.ts +40 -0
  40. package/src/test-db-sqlite.ts +1 -1
  41. package/src/test-shipped-sqlite-schema.ts +8 -6
  42. 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
- { threadMessage: threadMessageTable },
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
- // Below the trigram floor the predicate is the folded LIKE, and sqlite's
164
- // lower() folds ASCII only — `é` never matches a stored `CAFÉ`. Applying it
165
- // anyway would drop a row the caller's own matcher accepts, which is #459
166
- // again for that clause shape, so a short non-ASCII term narrows nothing.
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 trigram index for terms of three characters or more
21
- // (D4), the folded-LIKE scan for shorter terms. The search contract is accent-
22
- // and case-insensitive substring match over subject and sender.
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 trigram search folds diacritics both ways", async () => {
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 { CreateThreadMessageInput } from "@remit/data-ports";
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 trigram objects the migrator
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
- { threadMessage: threadMessageTable },
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 trigram index, so an old
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
- repo = createSqliteRepositories({
403
+ const repositories = createSqliteRepositories({
396
404
  app: harness.db,
397
405
  mail: harness.db,
398
406
  search: harness.db,
399
- }).threadMessage;
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 created = await repo.create(
416
- makeInput(accountConfigId, mailboxId, overrides),
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 the query match literally", async () => {
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: "50%" });
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 substring predicates are the one engine-specific
169
- // text-search seam; they live in ./thread-search-predicates.ts (the FTS5 trigram
170
- // index, with a folded LIKE fallback below three characters, RFC 036 D4).
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; an update that sets none of them
188
- // leaves the index as it is.
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 subjectMatch(term.contains);
252
+ if (term.field === "subject") return subjectContains(term.contains);
249
253
  if (term.field === "listId") return listIdMatch(term.contains);
250
- return fromMatch(term.contains);
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 trigram
1265
- * indexes, ordered by sent_date with an id tiebreak. `limit` is a page size
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
- // Accent- and case-insensitive substring match over the whole mailbox, isolated
5
- // here as the one text-search seam whose behaviour is engine-specific. The
6
- // subject and sender predicates match the `contains()` substring contract:
7
- // LIKE metacharacters (`\`, `%`, `_`) are escaped in JS so the needle arrives as
8
- // bind-parameter text, and the escaped form is treated literally.
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
- // Every predicate here is a condition over `thread_message_fts`, the FTS5
13
- // trigram index in the tenant search database (#1420). It indexes the folded
14
- // subject, the sender and the body preview the row carries, and MATCH is an
15
- // accent- and case-insensitive substring search (the tokenizer folds both
16
- // sides, so the needle is passed through untransformed). The predicate is a
17
- // `rowid IN (subquery)` over the same table, so terms combine with AND and OR
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
- * Below the trigram floor the predicate is the folded LIKE, and SQLite's
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
- export const subjectMatch = (term: string): SQL =>
64
- isTrigramIndexable(term)
65
- ? ftsRowidMatch(`subject : ${ftsPhrase(term)}`)
66
- : folded(fts.subject, term);
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 fromMatch = (term: string): SQL =>
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
- * 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
- : 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;
@@ -21,6 +21,7 @@ export {
21
21
  messagePlacementMoves,
22
22
  messageRecords,
23
23
  messageReferences,
24
+ messageSearchTexts,
24
25
  organizeJobRequests,
25
26
  outboxAttachments,
26
27
  outboxMessages,
@@ -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 created = await repo.create(
92
- input({
93
- subject: "Quarterly invoice",
94
- fromName: "Ada Lovelace",
95
- fromEmail: "ada@example.test",
96
- snippet: "the analytical engine",
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 trigram table the search migration set creates
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
- snippet: threadMessageTable.snippet,
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
- snippet: string | null;
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.snippet ?? "",
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
+ };
@@ -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 trigram path. The one
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 entry of migrationJournal(set)) {
70
- for (const statement of migrationSql(set, entry.tag).split(
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
+ };