@remit/drizzle-service 0.0.103 → 0.0.105

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 (66) hide show
  1. package/package.json +4 -3
  2. package/src/bound-parameters.test.ts +57 -0
  3. package/src/bound-parameters.ts +49 -0
  4. package/src/databases.ts +10 -9
  5. package/src/db.ts +1 -0
  6. package/src/index.ts +4 -0
  7. package/src/repair/junk-only-address.sqlite.test.ts +9 -5
  8. package/src/repos/account.conformance.d1.test.ts +20 -0
  9. package/src/repos/account.conformance.sqlite.test.ts +9 -5
  10. package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
  11. package/src/repos/calendar-default.d1.test.ts +4 -0
  12. package/src/repos/calendar-default.sqlite.test.ts +3 -76
  13. package/src/repos/calendar-default.suite.ts +68 -0
  14. package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
  15. package/src/repos/calendar-event-index.conformance.sqlite.test.ts +3 -1
  16. package/src/repos/calendar-event-index.ts +36 -21
  17. package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
  18. package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
  19. package/src/repos/calendar-put.d1.test.ts +4 -0
  20. package/src/repos/calendar-put.sqlite.test.ts +3 -209
  21. package/src/repos/calendar-put.suite.ts +210 -0
  22. package/src/repos/calendar-store.ts +17 -10
  23. package/src/repos/cascade-delete.test.ts +9 -2
  24. package/src/repos/cascade-delete.ts +3 -2
  25. package/src/repos/envelope.d1.test.ts +66 -0
  26. package/src/repos/envelope.sqlite.test.ts +5 -1
  27. package/src/repos/envelope.test.ts +5 -1
  28. package/src/repos/envelope.ts +40 -31
  29. package/src/repos/filter.d1.test.ts +4 -0
  30. package/src/repos/filter.suite.ts +476 -0
  31. package/src/repos/filter.test.ts +3 -472
  32. package/src/repos/i4-account-config.d1.test.ts +4 -0
  33. package/src/repos/i4-account-config.suite.ts +131 -0
  34. package/src/repos/i4-account-config.test.ts +3 -127
  35. package/src/repos/i4-account-export-request.d1.test.ts +4 -0
  36. package/src/repos/i4-account-export-request.suite.ts +44 -0
  37. package/src/repos/i4-account-export-request.test.ts +3 -40
  38. package/src/repos/i4-account-setting.d1.test.ts +4 -0
  39. package/src/repos/i4-account-setting.suite.ts +100 -0
  40. package/src/repos/i4-account-setting.test.ts +3 -93
  41. package/src/repos/i4-account.test.ts +17 -10
  42. package/src/repos/i4-address-junk-move.sqlite.test.ts +9 -5
  43. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +9 -5
  44. package/src/repos/i4-mailbox.sqlite.test.ts +17 -10
  45. package/src/repos/i4-mailbox.test.ts +17 -10
  46. package/src/repos/i4-outbox-attachment.test.ts +9 -5
  47. package/src/repos/label.conformance.d1.test.ts +20 -0
  48. package/src/repos/message.sqlite.test.ts +59 -1
  49. package/src/repos/message.test.ts +5 -1
  50. package/src/repos/message.ts +66 -0
  51. package/src/repos/sync-warning.ts +35 -22
  52. package/src/repos/thread-message-body-muted.sqlite.test.ts +32 -24
  53. package/src/repos/thread-message-field-terms.sqlite.test.ts +18 -10
  54. package/src/repos/thread-message.sqlite.test.ts +15 -9
  55. package/src/repos/thread-message.test.ts +54 -29
  56. package/src/repos/thread-message.ts +13 -9
  57. package/src/repos/thread-search-predicates.ts +36 -63
  58. package/src/schema/message-data.ts +3 -0
  59. package/src/schema-sets/mail.ts +1 -0
  60. package/src/search/thread-message-search.sqlite.test.ts +62 -14
  61. package/src/search/thread-message-search.ts +27 -21
  62. package/src/sqlite-repositories.ts +6 -3
  63. package/src/test-d1.ts +57 -0
  64. package/src/test-db-sqlite.ts +1 -1
  65. package/src/test-shipped-sqlite-schema.ts +8 -6
  66. package/src/test-stores.ts +43 -0
@@ -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,
@@ -3,8 +3,10 @@ import { after, before, describe, test } from "node:test";
3
3
  import type { CreateThreadMessageInput } from "@remit/data-ports";
4
4
  import Database from "better-sqlite3";
5
5
  import { drizzle } from "drizzle-orm/better-sqlite3";
6
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
6
7
  import { syncStore } from "../databases.js";
7
8
  import type { Db } from "../db.js";
9
+ import { messageSearchTextTable } from "../schema/message-data.js";
8
10
  import { createSqliteRepositories } from "../sqlite-repositories.js";
9
11
  import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
10
12
  import {
@@ -49,12 +51,18 @@ const open = (set: "mail" | "search") => {
49
51
  describe("the thread-message search index in its own database", () => {
50
52
  const mail = open("mail");
51
53
  const search = open("search");
52
- const index = new SearchIndex(mail.db, syncStore(search.db));
53
- const repo = createSqliteRepositories({
54
- app: mail.db,
55
- mail: mail.db,
56
- search: search.db,
57
- }).threadMessage;
54
+ const index = new SearchIndex(
55
+ mail.db,
56
+ syncStore(search.db, BETTER_SQLITE3_LIMITS),
57
+ );
58
+ const repo = createSqliteRepositories(
59
+ {
60
+ app: mail.db,
61
+ mail: mail.db,
62
+ search: search.db,
63
+ },
64
+ BETTER_SQLITE3_LIMITS,
65
+ ).threadMessage;
58
66
 
59
67
  const found = async (query: string): Promise<string[]> =>
60
68
  (await repo.searchByMailboxWindow(ACCOUNT, MAILBOX, { query })).items.map(
@@ -88,14 +96,18 @@ describe("the thread-message search index in its own database", () => {
88
96
  });
89
97
 
90
98
  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
- );
99
+ const message = input({
100
+ subject: "Quarterly invoice",
101
+ fromName: "Ada Lovelace",
102
+ fromEmail: "ada@example.test",
103
+ });
104
+ await mail.db.insert(messageSearchTextTable).values({
105
+ messageId: message.messageId,
106
+ text: "the analytical engine",
107
+ createdAt: 0,
108
+ updatedAt: 0,
109
+ });
110
+ const created = await repo.create(message);
99
111
 
100
112
  assert.deepEqual(await found("invoice"), [created.threadMessageId]);
101
113
  assert.deepEqual(await found("lovelace"), [created.threadMessageId]);
@@ -103,6 +115,19 @@ describe("the thread-message search index in its own database", () => {
103
115
  assert.deepEqual(await found("nowhere"), []);
104
116
  });
105
117
 
118
+ test("matches words by prefix, folding case and diacritics", async () => {
119
+ const created = await repo.create(
120
+ input({
121
+ subject: "Crème brûlée recipe",
122
+ fromName: "Matthijs van Henten",
123
+ }),
124
+ );
125
+
126
+ assert.deepEqual(await found("matt"), [created.threadMessageId]);
127
+ assert.deepEqual(await found("CREME brul"), [created.threadMessageId]);
128
+ assert.deepEqual(await found("thijs"), []);
129
+ });
130
+
106
131
  test("follows a rewritten subject and drops the old one", async () => {
107
132
  const created = await repo.create(input({ subject: "Draft agenda" }));
108
133
  await repo.update(ACCOUNT, created.threadMessageId, {
@@ -133,4 +158,27 @@ describe("the thread-message search index in its own database", () => {
133
158
  assert.equal(await index.needsRebuild(), false);
134
159
  assert.deepEqual(await found("receipt"), [created.threadMessageId]);
135
160
  });
161
+
162
+ test("matches the body text past the snippet, through a rewrite and a rebuild", async () => {
163
+ const created = await repo.create(
164
+ input({ subject: "Long letter", snippet: "Dear reader, a preview" }),
165
+ );
166
+ const text = `Dear reader, a preview ${"filler words ".repeat(200)}zygomorphic`;
167
+ await mail.db.insert(messageSearchTextTable).values({
168
+ messageId: created.messageId,
169
+ text,
170
+ createdAt: 0,
171
+ updatedAt: 0,
172
+ });
173
+ await index.sync([created.threadMessageId]);
174
+ assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
175
+
176
+ await repo.update(ACCOUNT, created.threadMessageId, {
177
+ subject: "Longer letter",
178
+ });
179
+ assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
180
+
181
+ await index.rebuild();
182
+ assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
183
+ });
136
184
  });
@@ -13,10 +13,12 @@ import {
13
13
  } from "drizzle-orm";
14
14
  import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
15
15
  import type { SqliteBatchItem, WriteBatch } from "../batch.js";
16
+ import { chunkRows } from "../bound-parameters.js";
16
17
  import type { RepositoryDb, SqliteStore } from "../db.js";
18
+ import { messageSearchTextTable } from "../schema/message-data.js";
17
19
  import { threadMessageTable } from "../schema/thread-message.js";
18
20
 
19
- // The FTS5 trigram table the search migration set creates
21
+ // The FTS5 table the search migration set creates
20
22
  // (deploy/vps/migrations-sqlite/search). drizzle-kit cannot express a virtual
21
23
  // table, so its DDL is that committed migration and this declaration only lets
22
24
  // drizzle build statements against it; no drizzle-kit config reads it.
@@ -36,10 +38,7 @@ type AnyDb = RepositoryDb<Record<string, unknown>>;
36
38
 
37
39
  type SearchDocument = typeof threadMessageSearchTable.$inferInsert;
38
40
 
39
- // D1 refuses a statement with more than 100 bound parameters. A document is
40
- // nine parameters, so ten documents per insert stay under it.
41
- const DOCUMENTS_PER_STATEMENT = 10;
42
- const DOCUMENTS_PER_REBUILD_PAGE = DOCUMENTS_PER_STATEMENT * 30;
41
+ const DOCUMENTS_PER_REBUILD_PAGE = 300;
43
42
 
44
43
  // The document's rowid, derived from its thread message id so a write or a
45
44
  // removal finds the document without a lookup. 53 bits, the integers a number
@@ -68,7 +67,7 @@ const indexedColumns = {
68
67
  subject: threadMessageTable.subject,
69
68
  fromName: threadMessageTable.fromName,
70
69
  fromEmail: threadMessageTable.fromEmail,
71
- snippet: threadMessageTable.snippet,
70
+ searchText: messageSearchTextTable.text,
72
71
  };
73
72
 
74
73
  interface IndexedRow {
@@ -80,7 +79,7 @@ interface IndexedRow {
80
79
  subject: string | null;
81
80
  fromName: string | null;
82
81
  fromEmail: string | null;
83
- snippet: string | null;
82
+ searchText: string | null;
84
83
  }
85
84
 
86
85
  const toDocument = (row: IndexedRow): SearchDocument => ({
@@ -92,17 +91,9 @@ const toDocument = (row: IndexedRow): SearchDocument => ({
92
91
  listId: row.listId ?? "",
93
92
  subject: row.subject ?? "",
94
93
  sender: `${row.fromName ?? ""} ${row.fromEmail ?? ""}`,
95
- body: row.snippet ?? "",
94
+ body: row.searchText ?? "",
96
95
  });
97
96
 
98
- const chunk = <T>(items: readonly T[], size: number): T[][] => {
99
- const chunks: T[][] = [];
100
- for (let start = 0; start < items.length; start += size) {
101
- chunks.push(items.slice(start, start + size));
102
- }
103
- return chunks;
104
- };
105
-
106
97
  /** Where a page of candidates resumes: the last one the previous page held. */
107
98
  export interface SearchPosition {
108
99
  rank: number;
@@ -140,6 +131,7 @@ export interface CandidateQuery {
140
131
  export class SearchIndex {
141
132
  private readonly search: AnyDb;
142
133
  private readonly batch: WriteBatch;
134
+ private readonly maxBoundParameters: number;
143
135
 
144
136
  constructor(
145
137
  private readonly mail: AnyDb,
@@ -147,15 +139,24 @@ export class SearchIndex {
147
139
  ) {
148
140
  this.search = store.db;
149
141
  this.batch = store.batch;
142
+ this.maxBoundParameters = store.maxBoundParameters;
150
143
  }
151
144
 
152
145
  /** Make the index match the mail database for these thread messages. */
153
146
  async sync(threadMessageIds: readonly string[]): Promise<void> {
154
147
  const ids = [...new Set(threadMessageIds)];
155
- for (const slice of chunk(ids, DOCUMENTS_PER_STATEMENT)) {
148
+ for (const slice of chunkRows(
149
+ threadMessageSearchTable,
150
+ ids,
151
+ this.maxBoundParameters,
152
+ )) {
156
153
  const rows = await this.mail
157
154
  .select(indexedColumns)
158
155
  .from(threadMessageTable)
156
+ .leftJoin(
157
+ messageSearchTextTable,
158
+ eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
159
+ )
159
160
  .where(inArray(threadMessageTable.threadMessageId, slice));
160
161
  const items: SqliteBatchItem[] = [
161
162
  this.search
@@ -257,16 +258,21 @@ export class SearchIndex {
257
258
  const rows = await this.mail
258
259
  .select(indexedColumns)
259
260
  .from(threadMessageTable)
261
+ .leftJoin(
262
+ messageSearchTextTable,
263
+ eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
264
+ )
260
265
  .where(gt(threadMessageTable.threadMessageId, after))
261
266
  .orderBy(asc(threadMessageTable.threadMessageId))
262
267
  .limit(DOCUMENTS_PER_REBUILD_PAGE);
263
268
  const last = rows.at(-1);
264
269
  if (!last) return indexed;
265
270
  await this.batch(
266
- chunk(rows, DOCUMENTS_PER_STATEMENT).map((slice) =>
267
- this.search
268
- .insert(threadMessageSearchTable)
269
- .values(slice.map(toDocument)),
271
+ chunkRows(threadMessageSearchTable, rows, this.maxBoundParameters).map(
272
+ (slice) =>
273
+ this.search
274
+ .insert(threadMessageSearchTable)
275
+ .values(slice.map(toDocument)),
270
276
  ),
271
277
  );
272
278
  indexed += rows.length;
@@ -1,3 +1,4 @@
1
+ import type { SqliteLimits } from "./bound-parameters.js";
1
2
  import {
2
3
  type SqliteDatabases,
3
4
  type SqliteStores,
@@ -67,7 +68,7 @@ export const composeSqliteRepositories = ({
67
68
  accountExportRequest: new AccountExportRequestRepo(app.db),
68
69
  configImport: new ConfigImportRepo(app.db),
69
70
  quarantine: new QuarantineRepo(mail.db),
70
- syncWarning: new SyncWarningRepo(mail.db),
71
+ syncWarning: new SyncWarningRepo(mail),
71
72
  organizeJobRequest: new OrganizeJobRequestRepo(mail.db),
72
73
  placementMove: new MessagePlacementMoveRepo(mail.db),
73
74
  flagPush: new MessageFlagPushRepo(mail.db),
@@ -77,7 +78,7 @@ export const composeSqliteRepositories = ({
77
78
  senderSignerStanding: new SenderSignerStandingRepo(mail.db),
78
79
  calendarCollection: new CalendarCollectionRepo(app.db),
79
80
  calendarObject: new CalendarObjectRepo(app.db),
80
- calendarEventIndex: new CalendarEventIndexRepo(app.db),
81
+ calendarEventIndex: new CalendarEventIndexRepo(app),
81
82
  calendarFeedToken: new CalendarFeedTokenRepo(app.db),
82
83
  calendarSuggestion,
83
84
  calendarStore: new DrizzleCalendarStore(app, calendarSuggestion),
@@ -88,4 +89,6 @@ export type SqliteRepositories = ReturnType<typeof composeSqliteRepositories>;
88
89
 
89
90
  export const createSqliteRepositories = (
90
91
  databases: SqliteDatabases,
91
- ): SqliteRepositories => composeSqliteRepositories(syncStores(databases));
92
+ limits: SqliteLimits,
93
+ ): SqliteRepositories =>
94
+ composeSqliteRepositories(syncStores(databases, limits));
package/src/test-d1.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { drizzle } from "drizzle-orm/d1";
2
+ import { Miniflare } from "miniflare";
3
+ import { asyncBatch } from "./batch.js";
4
+ import type { SqliteLimits } from "./bound-parameters.js";
5
+ import { syncStore } from "./databases.js";
6
+ import type { SqliteStore } from "./db.js";
7
+ import {
8
+ type MigrationSet,
9
+ migrationStatements,
10
+ } from "./test-shipped-sqlite-schema.js";
11
+ import { createShippedSqliteDb } from "./test-sqlite.js";
12
+ import type { OpenTestStores } from "./test-stores.js";
13
+
14
+ const CLOUDFLARE_LIMITS: SqliteLimits = { maxBoundParameters: 100 };
15
+
16
+ /** One in-process D1 database migrated with a committed set, written through `db.batch()`. */
17
+ export const openD1TestStore = async (
18
+ set: MigrationSet,
19
+ ): Promise<{
20
+ store: SqliteStore<Record<string, unknown>>;
21
+ close: () => Promise<void>;
22
+ }> => {
23
+ const miniflare = new Miniflare({
24
+ modules: true,
25
+ script: "export default {};",
26
+ d1Databases: { DB: set },
27
+ });
28
+ const d1 = await miniflare.getD1Database("DB");
29
+ const statements = migrationStatements(set).filter(
30
+ (statement) => statement.trim() !== "",
31
+ );
32
+ await d1.batch(statements.map((statement) => d1.prepare(statement)));
33
+ const db = drizzle(d1);
34
+ return {
35
+ store: { db, batch: asyncBatch(db), ...CLOUDFLARE_LIMITS },
36
+ close: () => miniflare.dispose(),
37
+ };
38
+ };
39
+
40
+ /**
41
+ * The stores a Cloudflare deployment runs the app repositories on: the app
42
+ * database on an in-process D1, migrated with the committed app set, written
43
+ * through `db.batch()`. The mail and search sets stay on a synchronous handle,
44
+ * as they do in the tenant mail Durable Object.
45
+ */
46
+ export const openD1TestStores: OpenTestStores = async () => {
47
+ const app = await openD1TestStore("app");
48
+ const shipped = createShippedSqliteDb();
49
+ const local = syncStore(shipped.db, CLOUDFLARE_LIMITS);
50
+ return {
51
+ stores: { app: app.store, mail: local, search: local },
52
+ close: async () => {
53
+ shipped.close();
54
+ await app.close();
55
+ },
56
+ };
57
+ };
@@ -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,43 @@
1
+ import { BETTER_SQLITE3_LIMITS } from "./bound-parameters.js";
2
+ import { type SqliteStores, syncStores } from "./databases.js";
3
+ import {
4
+ calendarEventIndexTable,
5
+ calendarObjectTable,
6
+ calendarTable,
7
+ } from "./schema.js";
8
+ import { createTestDb } from "./test-db.js";
9
+ import { createSqliteTestDb } from "./test-db-sqlite.js";
10
+
11
+ /** The stores a repository suite runs against, opened once per test file. */
12
+ export type OpenTestStores = () => Promise<{
13
+ stores: SqliteStores;
14
+ close: () => Promise<void>;
15
+ }>;
16
+
17
+ /** The whole schema on one in-memory better-sqlite3 handle that plays all three databases. */
18
+ export const openSqliteTestStores: OpenTestStores = async () => {
19
+ const { db, close } = await createTestDb();
20
+ return {
21
+ stores: syncStores(
22
+ { app: db, mail: db, search: db },
23
+ BETTER_SQLITE3_LIMITS,
24
+ ),
25
+ close,
26
+ };
27
+ };
28
+
29
+ /** The three calendar tables alone on one in-memory better-sqlite3 handle. */
30
+ export const openCalendarSqliteTestStores: OpenTestStores = async () => {
31
+ const { db, close } = await createSqliteTestDb({
32
+ calendars: calendarTable,
33
+ calendarObjects: calendarObjectTable,
34
+ calendarEventIndexes: calendarEventIndexTable,
35
+ });
36
+ return {
37
+ stores: syncStores(
38
+ { app: db, mail: db, search: db },
39
+ BETTER_SQLITE3_LIMITS,
40
+ ),
41
+ close,
42
+ };
43
+ };