@remit/drizzle-service 0.0.102 → 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 (88) hide show
  1. package/package.json +4 -3
  2. package/src/batch.sqlite.test.ts +4 -4
  3. package/src/batch.ts +47 -21
  4. package/src/databases.ts +24 -6
  5. package/src/db.ts +20 -5
  6. package/src/index.ts +12 -0
  7. package/src/repair/junk-only-address.sqlite.test.ts +7 -2
  8. package/src/repos/account.conformance.d1.test.ts +20 -0
  9. package/src/repos/account.conformance.sqlite.test.ts +6 -2
  10. package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
  11. package/src/repos/calendar-collection.ts +2 -2
  12. package/src/repos/calendar-default.d1.test.ts +4 -0
  13. package/src/repos/calendar-default.sqlite.test.ts +3 -72
  14. package/src/repos/calendar-default.suite.ts +68 -0
  15. package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
  16. package/src/repos/calendar-event-index.ts +2 -2
  17. package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
  18. package/src/repos/calendar-feed-token.ts +2 -2
  19. package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
  20. package/src/repos/calendar-object.ts +2 -2
  21. package/src/repos/calendar-put.d1.test.ts +4 -0
  22. package/src/repos/calendar-put.sqlite.test.ts +3 -205
  23. package/src/repos/calendar-put.suite.ts +210 -0
  24. package/src/repos/calendar-store.ts +36 -40
  25. package/src/repos/calendar-suggestion.ts +2 -2
  26. package/src/repos/cascade-delete.test.ts +3 -2
  27. package/src/repos/cascade-delete.ts +12 -8
  28. package/src/repos/envelope.sqlite.test.ts +3 -2
  29. package/src/repos/envelope.test.ts +3 -2
  30. package/src/repos/envelope.ts +14 -9
  31. package/src/repos/filter.d1.test.ts +4 -0
  32. package/src/repos/filter.suite.ts +476 -0
  33. package/src/repos/filter.test.ts +3 -472
  34. package/src/repos/filter.ts +2 -2
  35. package/src/repos/i4-account-config.d1.test.ts +4 -0
  36. package/src/repos/i4-account-config.suite.ts +131 -0
  37. package/src/repos/i4-account-config.test.ts +3 -123
  38. package/src/repos/i4-account-config.ts +8 -10
  39. package/src/repos/i4-account-export-request.d1.test.ts +4 -0
  40. package/src/repos/i4-account-export-request.suite.ts +44 -0
  41. package/src/repos/i4-account-export-request.test.ts +3 -40
  42. package/src/repos/i4-account-export-request.ts +2 -2
  43. package/src/repos/i4-account-setting.d1.test.ts +4 -0
  44. package/src/repos/i4-account-setting.suite.ts +100 -0
  45. package/src/repos/i4-account-setting.test.ts +3 -93
  46. package/src/repos/i4-account-setting.ts +2 -2
  47. package/src/repos/i4-account.test.ts +12 -8
  48. package/src/repos/i4-account.ts +6 -10
  49. package/src/repos/i4-address-junk-move.sqlite.test.ts +7 -2
  50. package/src/repos/i4-address.ts +12 -2
  51. package/src/repos/i4-config-import.ts +2 -2
  52. package/src/repos/i4-mailbox-lock.ts +2 -2
  53. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +12 -10
  54. package/src/repos/i4-mailbox-special-use.ts +29 -40
  55. package/src/repos/i4-mailbox.sqlite.test.ts +12 -10
  56. package/src/repos/i4-mailbox.test.ts +15 -14
  57. package/src/repos/i4-mailbox.ts +13 -7
  58. package/src/repos/i4-message-flag-push.ts +2 -2
  59. package/src/repos/i4-message-placement-move.ts +2 -2
  60. package/src/repos/i4-organize-job-request.ts +2 -2
  61. package/src/repos/i4-outbox-attachment.test.ts +7 -2
  62. package/src/repos/i4-outbox-attachment.ts +11 -5
  63. package/src/repos/i4-outbox-message.ts +2 -2
  64. package/src/repos/i4-sender-signer-standing.ts +2 -2
  65. package/src/repos/label.conformance.d1.test.ts +20 -0
  66. package/src/repos/label.ts +2 -2
  67. package/src/repos/message-flag.ts +3 -6
  68. package/src/repos/message-label.ts +2 -2
  69. package/src/repos/message.sqlite.test.ts +57 -3
  70. package/src/repos/message.test.ts +6 -5
  71. package/src/repos/message.ts +81 -10
  72. package/src/repos/quarantine.ts +2 -2
  73. package/src/repos/sync-warning.ts +2 -2
  74. package/src/repos/thread-message-body-muted.sqlite.test.ts +30 -22
  75. package/src/repos/thread-message-field-terms.sqlite.test.ts +16 -11
  76. package/src/repos/thread-message.sqlite.test.ts +13 -7
  77. package/src/repos/thread-message.test.ts +43 -27
  78. package/src/repos/thread-message.ts +26 -14
  79. package/src/repos/thread-search-predicates.ts +36 -63
  80. package/src/schema/message-data.ts +3 -0
  81. package/src/schema-sets/mail.ts +1 -0
  82. package/src/search/thread-message-search.sqlite.test.ts +57 -11
  83. package/src/search/thread-message-search.ts +26 -12
  84. package/src/sqlite-repositories.ts +48 -32
  85. package/src/test-d1.ts +40 -0
  86. package/src/test-db-sqlite.ts +1 -1
  87. package/src/test-shipped-sqlite-schema.ts +8 -6
  88. package/src/test-stores.ts +30 -0
@@ -23,8 +23,8 @@ import {
23
23
  type SQLWrapper,
24
24
  sql,
25
25
  } from "drizzle-orm";
26
- import { runBatch } from "../batch.js";
27
- import type { Db } from "../db.js";
26
+ import type { WriteBatch } from "../batch.js";
27
+ import type { RepositoryDb, SqliteStore } from "../db.js";
28
28
  import { NotFoundError } from "../error.js";
29
29
  import { deterministicBase36Id } from "../id.js";
30
30
  import { decodeToken } from "../pagination.js";
@@ -37,13 +37,17 @@ 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
 
49
+ type DB = RepositoryDb<Record<string, unknown>>;
50
+
47
51
  export const deriveThreadMessageId = (
48
52
  threadId: string,
49
53
  messageId: string,
@@ -163,9 +167,10 @@ function toItem(row: Row): ThreadMessageItem {
163
167
 
164
168
  // ─── Search predicates ────────────────────────────────────────────────────────
165
169
 
166
- // The accent-/case-insensitive substring predicates are the one engine-specific
167
- // text-search seam; they live in ./thread-search-predicates.ts (the FTS5 trigram
168
- // 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).
169
174
 
170
175
  /**
171
176
  * Whether the row's From address is muted, as a correlated subquery over the
@@ -182,8 +187,9 @@ function toItem(row: Row): ThreadMessageItem {
182
187
  const mutedSender = (): SQL =>
183
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)`;
184
189
 
185
- // The columns the search index is built from; an update that sets none of them
186
- // 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.
187
193
  const touchesSearchIndex = (input: UpdateThreadMessageInput): boolean =>
188
194
  input.mailboxId !== undefined ||
189
195
  input.sentDate !== undefined ||
@@ -243,9 +249,9 @@ function buildSearchConditions(search: SearchOptions): SearchConditions {
243
249
  }
244
250
 
245
251
  const fieldTermCondition = (term: ThreadMessageFieldTerm): SQL => {
246
- if (term.field === "subject") return subjectMatch(term.contains);
252
+ if (term.field === "subject") return subjectContains(term.contains);
247
253
  if (term.field === "listId") return listIdMatch(term.contains);
248
- return fromMatch(term.contains);
254
+ return fromContains(term.contains);
249
255
  };
250
256
 
251
257
  // Combine the caller's terms into one condition, or `undefined` when there are
@@ -326,10 +332,16 @@ function sentDateCursorCond(
326
332
  export class DrizzleThreadMessageRepository
327
333
  implements IThreadMessageRepository
328
334
  {
335
+ private readonly db: DB;
336
+ private readonly batch: WriteBatch;
337
+
329
338
  constructor(
330
- private readonly db: Db<Record<string, unknown>>,
339
+ store: SqliteStore<Record<string, unknown>>,
331
340
  private readonly search: SearchIndex,
332
- ) {}
341
+ ) {
342
+ this.db = store.db;
343
+ this.batch = store.batch;
344
+ }
333
345
 
334
346
  /**
335
347
  * One page of rows in date order (ranked first when asked). Without a text
@@ -1082,7 +1094,7 @@ export class DrizzleThreadMessageRepository
1082
1094
  .returning({ threadMessageId: threadMessageTable.threadMessageId }),
1083
1095
  ];
1084
1096
  });
1085
- const results = await runBatch(this.db, items);
1097
+ const results = await this.batch(items);
1086
1098
  await this.search.sync(
1087
1099
  rows.flatMap((row) => [
1088
1100
  row.threadMessageId,
@@ -1253,8 +1265,8 @@ export class DrizzleThreadMessageRepository
1253
1265
 
1254
1266
  /**
1255
1267
  * Mailbox search for `searchThreads`. Matching (subject / from / query and
1256
- * the boolean filters) runs in SQL over the whole mailbox via the trigram
1257
- * 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
1258
1270
  * over MATCHES (clamped to THREAD_SEARCH_MAX_LIMIT); the DynamoDB name is
1259
1271
  * kept for interface parity, but there is no recent-window read bound — the
1260
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 { syncStore } from "../databases.js";
6
7
  import type { Db } from "../db.js";
7
- import { DrizzleThreadMessageRepository } from "../repos/thread-message.js";
8
+ import { messageSearchTextTable } from "../schema/message-data.js";
9
+ import { createSqliteRepositories } from "../sqlite-repositories.js";
8
10
  import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
9
11
  import {
10
12
  SearchIndex,
@@ -48,8 +50,12 @@ const open = (set: "mail" | "search") => {
48
50
  describe("the thread-message search index in its own database", () => {
49
51
  const mail = open("mail");
50
52
  const search = open("search");
51
- const index = new SearchIndex(mail.db, search.db);
52
- const repo = new DrizzleThreadMessageRepository(mail.db, index);
53
+ const index = new SearchIndex(mail.db, syncStore(search.db));
54
+ const repo = createSqliteRepositories({
55
+ app: mail.db,
56
+ mail: mail.db,
57
+ search: search.db,
58
+ }).threadMessage;
53
59
 
54
60
  const found = async (query: string): Promise<string[]> =>
55
61
  (await repo.searchByMailboxWindow(ACCOUNT, MAILBOX, { query })).items.map(
@@ -83,14 +89,18 @@ describe("the thread-message search index in its own database", () => {
83
89
  });
84
90
 
85
91
  test("finds a message by subject, sender and body once it is written", async () => {
86
- const created = await repo.create(
87
- input({
88
- subject: "Quarterly invoice",
89
- fromName: "Ada Lovelace",
90
- fromEmail: "ada@example.test",
91
- snippet: "the analytical engine",
92
- }),
93
- );
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);
94
104
 
95
105
  assert.deepEqual(await found("invoice"), [created.threadMessageId]);
96
106
  assert.deepEqual(await found("lovelace"), [created.threadMessageId]);
@@ -98,6 +108,19 @@ describe("the thread-message search index in its own database", () => {
98
108
  assert.deepEqual(await found("nowhere"), []);
99
109
  });
100
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
+
101
124
  test("follows a rewritten subject and drops the old one", async () => {
102
125
  const created = await repo.create(input({ subject: "Draft agenda" }));
103
126
  await repo.update(ACCOUNT, created.threadMessageId, {
@@ -128,4 +151,27 @@ describe("the thread-message search index in its own database", () => {
128
151
  assert.equal(await index.needsRebuild(), false);
129
152
  assert.deepEqual(await found("receipt"), [created.threadMessageId]);
130
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
+ });
131
177
  });
@@ -12,11 +12,12 @@ import {
12
12
  sql,
13
13
  } from "drizzle-orm";
14
14
  import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
15
- import { runBatch, type SqliteBatchItem } from "../batch.js";
16
- import type { Db } from "../db.js";
15
+ import type { SqliteBatchItem, WriteBatch } from "../batch.js";
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.
@@ -32,7 +33,7 @@ export const threadMessageSearchTable = sqliteTable("thread_message_fts", {
32
33
  body: text("body").notNull(),
33
34
  });
34
35
 
35
- type AnyDb = Db<Record<string, unknown>>;
36
+ type AnyDb = RepositoryDb<Record<string, unknown>>;
36
37
 
37
38
  type SearchDocument = typeof threadMessageSearchTable.$inferInsert;
38
39
 
@@ -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[][] => {
@@ -138,10 +139,16 @@ export interface CandidateQuery {
138
139
  * only, so no statement carries more ids than a page holds.
139
140
  */
140
141
  export class SearchIndex {
142
+ private readonly search: AnyDb;
143
+ private readonly batch: WriteBatch;
144
+
141
145
  constructor(
142
146
  private readonly mail: AnyDb,
143
- private readonly search: AnyDb,
144
- ) {}
147
+ store: SqliteStore<Record<string, unknown>>,
148
+ ) {
149
+ this.search = store.db;
150
+ this.batch = store.batch;
151
+ }
145
152
 
146
153
  /** Make the index match the mail database for these thread messages. */
147
154
  async sync(threadMessageIds: readonly string[]): Promise<void> {
@@ -150,6 +157,10 @@ export class SearchIndex {
150
157
  const rows = await this.mail
151
158
  .select(indexedColumns)
152
159
  .from(threadMessageTable)
160
+ .leftJoin(
161
+ messageSearchTextTable,
162
+ eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
163
+ )
153
164
  .where(inArray(threadMessageTable.threadMessageId, slice));
154
165
  const items: SqliteBatchItem[] = [
155
166
  this.search
@@ -165,7 +176,7 @@ export class SearchIndex {
165
176
  .values(rows.map(toDocument)),
166
177
  );
167
178
  }
168
- await runBatch(this.search, items);
179
+ await this.batch(items);
169
180
  }
170
181
  }
171
182
 
@@ -251,13 +262,16 @@ export class SearchIndex {
251
262
  const rows = await this.mail
252
263
  .select(indexedColumns)
253
264
  .from(threadMessageTable)
265
+ .leftJoin(
266
+ messageSearchTextTable,
267
+ eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
268
+ )
254
269
  .where(gt(threadMessageTable.threadMessageId, after))
255
270
  .orderBy(asc(threadMessageTable.threadMessageId))
256
271
  .limit(DOCUMENTS_PER_REBUILD_PAGE);
257
272
  const last = rows.at(-1);
258
273
  if (!last) return indexed;
259
- await runBatch(
260
- this.search,
274
+ await this.batch(
261
275
  chunk(rows, DOCUMENTS_PER_STATEMENT).map((slice) =>
262
276
  this.search
263
277
  .insert(threadMessageSearchTable)
@@ -1,4 +1,8 @@
1
- import type { SqliteDatabases } from "./databases.js";
1
+ import {
2
+ type SqliteDatabases,
3
+ type SqliteStores,
4
+ syncStores,
5
+ } from "./databases.js";
2
6
  import { CalendarCollectionRepo } from "./repos/calendar-collection.js";
3
7
  import { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
4
8
  import { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
@@ -31,45 +35,57 @@ import { SyncWarningRepo } from "./repos/sync-warning.js";
31
35
  import { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
32
36
  import { SearchIndex } from "./search/thread-message-search.js";
33
37
 
34
- // Every repo over the database that holds its tables (#1420).
35
- export const createSqliteRepositories = ({
38
+ export const composeSqliteRepositories = ({
36
39
  app,
37
40
  mail,
38
41
  search,
39
- }: SqliteDatabases) => {
40
- const searchIndex = new SearchIndex(mail, search);
42
+ }: SqliteStores) => {
43
+ const searchIndex = new SearchIndex(mail.db, search);
44
+ const accountSetting = new AccountSettingRepo(app.db);
45
+ const address = new AddressRepo(mail.db);
46
+ const mailbox = new MailboxRepo(mail, searchIndex);
47
+ const account = new AccountRepo(app.db, mailbox);
48
+ const calendarSuggestion = new CalendarSuggestionRepo(mail.db);
41
49
  return {
42
- accountConfig: new AccountConfigRepo(app, mail),
43
- account: new AccountRepo(app, mail),
44
- accountSetting: new AccountSettingRepo(app),
45
- address: new AddressRepo(mail),
46
- mailbox: new MailboxRepo(mail, searchIndex),
47
- mailboxSpecialUse: new MailboxSpecialUseRepo(app, mail),
48
- mailboxLock: new MailboxLockRepo(mail),
50
+ accountConfig: new AccountConfigRepo(app.db, address),
51
+ account,
52
+ accountSetting,
53
+ address,
54
+ mailbox,
55
+ mailboxSpecialUse: new MailboxSpecialUseRepo(
56
+ mail.db,
57
+ account,
58
+ accountSetting,
59
+ ),
60
+ mailboxLock: new MailboxLockRepo(mail.db),
49
61
  message: new DrizzleMessageRepository(mail, searchIndex),
50
- messageFlag: new DrizzleMessageFlagRepository(mail),
51
- outboxMessage: new OutboxMessageRepo(mail),
62
+ messageFlag: new DrizzleMessageFlagRepository(mail.db),
63
+ outboxMessage: new OutboxMessageRepo(mail.db),
52
64
  outboxAttachment: new OutboxAttachmentRepo(mail),
53
65
  threadMessage: new DrizzleThreadMessageRepository(mail, searchIndex),
54
66
  envelope: new DrizzleEnvelopeRepository(mail),
55
- accountExportRequest: new AccountExportRequestRepo(app),
56
- configImport: new ConfigImportRepo(app),
57
- quarantine: new QuarantineRepo(mail),
58
- syncWarning: new SyncWarningRepo(mail),
59
- organizeJobRequest: new OrganizeJobRequestRepo(mail),
60
- placementMove: new MessagePlacementMoveRepo(mail),
61
- flagPush: new MessageFlagPushRepo(mail),
62
- filter: new FilterRepo(app),
63
- label: new LabelRepo(app),
64
- messageLabel: new MessageLabelRepo(mail),
65
- senderSignerStanding: new SenderSignerStandingRepo(mail),
66
- calendarCollection: new CalendarCollectionRepo(app),
67
- calendarObject: new CalendarObjectRepo(app),
68
- calendarEventIndex: new CalendarEventIndexRepo(app),
69
- calendarFeedToken: new CalendarFeedTokenRepo(app),
70
- calendarSuggestion: new CalendarSuggestionRepo(mail),
71
- calendarStore: new DrizzleCalendarStore(app, mail),
67
+ accountExportRequest: new AccountExportRequestRepo(app.db),
68
+ configImport: new ConfigImportRepo(app.db),
69
+ quarantine: new QuarantineRepo(mail.db),
70
+ syncWarning: new SyncWarningRepo(mail.db),
71
+ organizeJobRequest: new OrganizeJobRequestRepo(mail.db),
72
+ placementMove: new MessagePlacementMoveRepo(mail.db),
73
+ flagPush: new MessageFlagPushRepo(mail.db),
74
+ filter: new FilterRepo(app.db),
75
+ label: new LabelRepo(app.db),
76
+ messageLabel: new MessageLabelRepo(mail.db),
77
+ senderSignerStanding: new SenderSignerStandingRepo(mail.db),
78
+ calendarCollection: new CalendarCollectionRepo(app.db),
79
+ calendarObject: new CalendarObjectRepo(app.db),
80
+ calendarEventIndex: new CalendarEventIndexRepo(app.db),
81
+ calendarFeedToken: new CalendarFeedTokenRepo(app.db),
82
+ calendarSuggestion,
83
+ calendarStore: new DrizzleCalendarStore(app, calendarSuggestion),
72
84
  };
73
85
  };
74
86
 
75
- export type SqliteRepositories = ReturnType<typeof createSqliteRepositories>;
87
+ export type SqliteRepositories = ReturnType<typeof composeSqliteRepositories>;
88
+
89
+ export const createSqliteRepositories = (
90
+ databases: SqliteDatabases,
91
+ ): SqliteRepositories => composeSqliteRepositories(syncStores(databases));
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
  };