@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
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
3
4
  import {
4
5
  envelopeId as deriveEnvelopeId,
5
6
  rootBodyPartId as deriveRootBodyPartId,
@@ -43,7 +44,10 @@ describe("DrizzleMessageRepository (sqlite)", () => {
43
44
 
44
45
  before(async () => {
45
46
  ({ db, close } = await createSqliteTestDb(SCHEMA));
46
- repo = createSqliteRepositories({ app: db, mail: db, search: db }).message;
47
+ repo = createSqliteRepositories(
48
+ { app: db, mail: db, search: db },
49
+ BETTER_SQLITE3_LIMITS,
50
+ ).message;
47
51
  });
48
52
 
49
53
  after(async () => {
@@ -279,4 +283,58 @@ describe("DrizzleMessageRepository (sqlite)", () => {
279
283
  await repo.delete(MESSAGE_ID);
280
284
  await assert.rejects(() => repo.get(MESSAGE_ID));
281
285
  });
286
+
287
+ test("listMissingSearchText names stored bodies without search text, after the cursor", async () => {
288
+ const id = (n: number): string => `00000000-0000-0000-3333-00000000000${n}`;
289
+ for (const n of [1, 2, 3, 4]) {
290
+ await repo.create({ ...BASE_INPUT, messageId: id(n), uid: 100 + n });
291
+ }
292
+ for (const n of [1, 2, 4]) {
293
+ await repo.update(id(n), { bodyStorageKey: `file:///bodies/${n}` });
294
+ }
295
+ await repo.setSearchText(id(2), "already indexed");
296
+
297
+ const ours = (rows: Array<{ messageId: string }>): string[] =>
298
+ rows
299
+ .map((row) => row.messageId)
300
+ .filter((messageId) => messageId.includes("-3333-"));
301
+
302
+ assert.deepEqual(ours(await repo.listMissingSearchText("", 100)), [
303
+ id(1),
304
+ id(4),
305
+ ]);
306
+ assert.deepEqual(ours(await repo.listMissingSearchText(id(1), 100)), [
307
+ id(4),
308
+ ]);
309
+ assert.deepEqual(
310
+ await repo
311
+ .listMissingSearchText(id(1), 100)
312
+ .then((rows) => rows.find((row) => row.messageId === id(4))),
313
+ {
314
+ messageId: id(4),
315
+ bodyStorageKey: "file:///bodies/4",
316
+ },
317
+ );
318
+ });
319
+
320
+ test("copySearchText gives a copy the source's search text", async () => {
321
+ const source = "00000000-0000-0000-4444-000000000001";
322
+ const copy = "00000000-0000-0000-4444-000000000002";
323
+ for (const [messageId, uid] of [
324
+ [source, 201],
325
+ [copy, 202],
326
+ ] as const) {
327
+ await repo.create({ ...BASE_INPUT, messageId, uid });
328
+ await repo.update(messageId, { bodyStorageKey: "file:///bodies/shared" });
329
+ }
330
+ await repo.setSearchText(source, "the shared body");
331
+
332
+ await repo.copySearchText(source, copy);
333
+
334
+ const missing = await repo.listMissingSearchText("", 100);
335
+ assert.equal(
336
+ missing.some((row) => row.messageId === copy),
337
+ false,
338
+ );
339
+ });
282
340
  });
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
3
4
  import {
4
5
  envelopeId as deriveEnvelopeId,
5
6
  rootBodyPartId as deriveRootBodyPartId,
@@ -37,7 +38,10 @@ describe("DrizzleMessageRepository", () => {
37
38
 
38
39
  before(async () => {
39
40
  ({ db, stop } = await createTestDb());
40
- const repos = createSqliteRepositories({ app: db, mail: db, search: db });
41
+ const repos = createSqliteRepositories(
42
+ { app: db, mail: db, search: db },
43
+ BETTER_SQLITE3_LIMITS,
44
+ );
41
45
  messageRepo = repos.message;
42
46
  envelopeRepo = repos.envelope;
43
47
  });
@@ -39,6 +39,7 @@ import {
39
39
  envelopeTable,
40
40
  messageFlagTable,
41
41
  messageReferenceTable,
42
+ messageSearchTextTable,
42
43
  messageTable,
43
44
  rawMessageStorageTable,
44
45
  } from "../schema/message-data.js";
@@ -153,6 +154,9 @@ export function deleteMessageSubtree(
153
154
  .where(
154
155
  and(inList(bodyStructureRefetchTable.messageId, messageIds), guard),
155
156
  ),
157
+ db
158
+ .delete(messageSearchTextTable)
159
+ .where(and(inList(messageSearchTextTable.messageId, messageIds), guard)),
156
160
  db
157
161
  .delete(envelopeTable)
158
162
  .where(and(inList(envelopeTable.messageId, messageIds), guard)),
@@ -444,6 +448,68 @@ export class DrizzleMessageRepository implements IMessageRepository {
444
448
  return this.get(messageId);
445
449
  }
446
450
 
451
+ async setSearchText(messageId: string, text: string): Promise<void> {
452
+ const now = Date.now();
453
+ await this.db
454
+ .insert(messageSearchTextTable)
455
+ .values({ messageId, text, createdAt: now, updatedAt: now })
456
+ .onConflictDoUpdate({
457
+ target: messageSearchTextTable.messageId,
458
+ set: { text, updatedAt: now },
459
+ });
460
+ }
461
+
462
+ async copySearchText(
463
+ sourceMessageId: string,
464
+ targetMessageId: string,
465
+ ): Promise<void> {
466
+ const [source] = await this.db
467
+ .select({ text: messageSearchTextTable.text })
468
+ .from(messageSearchTextTable)
469
+ .where(eq(messageSearchTextTable.messageId, sourceMessageId));
470
+ if (!source) return;
471
+ await this.setSearchText(targetMessageId, source.text);
472
+ }
473
+
474
+ async syncSearch(messageIds: string[]): Promise<void> {
475
+ if (messageIds.length === 0) return;
476
+ const listings = await this.db
477
+ .select({ threadMessageId: threadMessageTable.threadMessageId })
478
+ .from(threadMessageTable)
479
+ .where(inList(threadMessageTable.messageId, messageIds));
480
+ await this.search.sync(listings.map((row) => row.threadMessageId));
481
+ }
482
+
483
+ async listMissingSearchText(
484
+ after: string,
485
+ limit: number,
486
+ ): ReturnType<IMessageRepository["listMissingSearchText"]> {
487
+ const rows = await this.db
488
+ .select({
489
+ messageId: messageTable.messageId,
490
+ bodyStorageKey: messageTable.bodyStorageKey,
491
+ })
492
+ .from(messageTable)
493
+ .leftJoin(
494
+ messageSearchTextTable,
495
+ eq(messageSearchTextTable.messageId, messageTable.messageId),
496
+ )
497
+ .where(
498
+ and(
499
+ gt(messageTable.messageId, after),
500
+ isNotNull(messageTable.bodyStorageKey),
501
+ isNull(messageSearchTextTable.messageId),
502
+ ),
503
+ )
504
+ .orderBy(asc(messageTable.messageId))
505
+ .limit(limit);
506
+ return rows.flatMap((row) =>
507
+ row.bodyStorageKey
508
+ ? [{ messageId: row.messageId, bodyStorageKey: row.bodyStorageKey }]
509
+ : [],
510
+ );
511
+ }
512
+
447
513
  async delete(messageId: string): Promise<void> {
448
514
  await this.deleteMany([messageId]);
449
515
  }
@@ -6,14 +6,24 @@ import type {
6
6
  } from "@remit/data-ports";
7
7
  import { deriveSyncWarningId } from "@remit/data-ports/id";
8
8
  import { and, count, desc, eq, gte, sql } from "drizzle-orm";
9
- import type { RepositoryDb } from "../db.js";
9
+ import type { WriteBatch } from "../batch.js";
10
+ import { chunkRows } from "../bound-parameters.js";
11
+ import type { RepositoryDb, SqliteStore } from "../db.js";
10
12
  import { syncWarningTable } from "../schema/sync-warning.js";
11
13
  import { inList } from "./in-list.js";
12
14
 
13
15
  type DB = RepositoryDb<Record<string, unknown>>;
14
16
 
15
17
  export class SyncWarningRepo implements ISyncWarningRepository {
16
- constructor(private db: DB) {}
18
+ private readonly db: DB;
19
+ private readonly batch: WriteBatch;
20
+ private readonly maxBoundParameters: number;
21
+
22
+ constructor(store: SqliteStore<Record<string, unknown>>) {
23
+ this.db = store.db;
24
+ this.batch = store.batch;
25
+ this.maxBoundParameters = store.maxBoundParameters;
26
+ }
17
27
 
18
28
  listByMailboxId = async (mailboxId: string): Promise<SyncWarningItem[]> =>
19
29
  this.db
@@ -46,26 +56,29 @@ export class SyncWarningRepo implements ISyncWarningRepository {
46
56
  recordFailures = async (keys: SyncWarningKey[]): Promise<void> => {
47
57
  if (keys.length === 0) return;
48
58
  const now = Date.now();
49
- await this.db
50
- .insert(syncWarningTable)
51
- .values(
52
- keys.map((key) => ({
53
- syncWarningId: deriveSyncWarningId(
54
- key.accountId,
55
- key.mailboxId,
56
- key.uidValidity,
57
- key.uid,
58
- key.stage,
59
- ),
60
- ...key,
61
- attempts: 1,
62
- createdAt: now,
63
- })),
64
- )
65
- .onConflictDoUpdate({
66
- target: syncWarningTable.syncWarningId,
67
- set: { attempts: sql`${syncWarningTable.attempts} + 1` },
68
- });
59
+ const rows = keys.map((key) => ({
60
+ syncWarningId: deriveSyncWarningId(
61
+ key.accountId,
62
+ key.mailboxId,
63
+ key.uidValidity,
64
+ key.uid,
65
+ key.stage,
66
+ ),
67
+ ...key,
68
+ attempts: 1,
69
+ createdAt: now,
70
+ }));
71
+ await this.batch(
72
+ chunkRows(syncWarningTable, rows, this.maxBoundParameters).map((slice) =>
73
+ this.db
74
+ .insert(syncWarningTable)
75
+ .values(slice)
76
+ .onConflictDoUpdate({
77
+ target: syncWarningTable.syncWarningId,
78
+ set: { attempts: sql`${syncWarningTable.attempts} + 1` },
79
+ }),
80
+ ),
81
+ );
69
82
  };
70
83
 
71
84
  deleteMany = async (syncWarningIds: string[]): Promise<void> => {
@@ -10,17 +10,14 @@
10
10
  import assert from "node:assert/strict";
11
11
  import { after, before, describe, test } from "node:test";
12
12
  import type { CreateThreadMessageInput } from "@remit/data-ports";
13
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
13
14
  import { addressTable } from "../schema/i4-address.js";
15
+ import { messageSearchTextTable } from "../schema/message-data.js";
14
16
  import { threadMessageTable } from "../schema/thread-message.js";
15
17
  import { createSqliteRepositories } from "../sqlite-repositories.js";
16
18
  import { createSqliteTestDb } from "../test-db-sqlite.js";
17
- import { shippedIndexDdl } from "../test-shipped-sqlite-schema.js";
18
19
  import type { DrizzleThreadMessageRepository } from "./thread-message.js";
19
20
 
20
- // The index the mail baseline creates for this predicate, read from the one
21
- // committed source so the plan assertion below is about the real object.
22
- const mutedIndexDdl = shippedIndexDdl("address_by_normalized_email");
23
-
24
21
  const ACCOUNT = "acct-seam";
25
22
  const MAILBOX = "mbx-seam";
26
23
  const SCOPE = new Set([MAILBOX]);
@@ -57,15 +54,21 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
57
54
 
58
55
  before(async () => {
59
56
  ({ db, sqlite, close } = await createSqliteTestDb(
60
- { threadMessage: threadMessageTable, address: addressTable },
57
+ {
58
+ threadMessage: threadMessageTable,
59
+ address: addressTable,
60
+ messageSearchText: messageSearchTextTable,
61
+ },
61
62
  { searchIndex: true },
62
63
  ));
63
- sqlite.exec(mutedIndexDdl);
64
- repo = createSqliteRepositories({
65
- app: db,
66
- mail: db,
67
- search: db,
68
- }).threadMessage;
64
+ repo = createSqliteRepositories(
65
+ {
66
+ app: db,
67
+ mail: db,
68
+ search: db,
69
+ },
70
+ BETTER_SQLITE3_LIMITS,
71
+ ).threadMessage;
69
72
  });
70
73
 
71
74
  after(async () => {
@@ -86,15 +89,20 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
86
89
  }),
87
90
  );
88
91
  }
89
- await repo.create(
90
- makeInput({
91
- subject: "unrelated note",
92
- fromEmail: "noise@example.com",
93
- fromName: "Noise",
94
- snippet: "Your parcel was left with the concierge",
95
- sentDate: BASE_DATE,
96
- }),
97
- );
92
+ const parcel = makeInput({
93
+ subject: "unrelated note",
94
+ fromEmail: "noise@example.com",
95
+ fromName: "Noise",
96
+ snippet: "Your parcel was left with the concierge",
97
+ sentDate: BASE_DATE,
98
+ });
99
+ await db.insert(messageSearchTextTable).values({
100
+ messageId: parcel.messageId,
101
+ text: "Your parcel was left with the concierge",
102
+ createdAt: 0,
103
+ updatedAt: 0,
104
+ });
105
+ await repo.create(parcel);
98
106
  });
99
107
 
100
108
  test("the term reaches the body text, not just subject and From", async () => {
@@ -123,16 +131,16 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
123
131
  assert.equal(result.items.length, 1);
124
132
  });
125
133
 
126
- test("a term under the trigram floor reads the body too", async () => {
134
+ test("a short word prefix reads the body too", async () => {
127
135
  const result = await repo.searchByDate(
128
136
  ACCOUNT,
129
- { query: "ge" },
137
+ { query: "co" },
130
138
  { mailboxIds: SCOPE, limit: 50 },
131
139
  );
132
140
 
133
141
  assert.ok(
134
142
  result.items.some((item) => item.snippet?.includes("concierge")),
135
- "the folded scan matched the body preview",
143
+ "the prefix matched the body preview",
136
144
  );
137
145
  });
138
146
 
@@ -1,6 +1,8 @@
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 { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
5
+ import { messageSearchTextTable } from "../schema/message-data.js";
4
6
  import { threadMessageTable } from "../schema/thread-message.js";
5
7
  import { createSqliteRepositories } from "../sqlite-repositories.js";
6
8
  import { createSqliteTestDb } from "../test-db-sqlite.js";
@@ -51,15 +53,21 @@ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () =>
51
53
 
52
54
  before(async () => {
53
55
  const created = await createSqliteTestDb(
54
- { threadMessage: threadMessageTable },
56
+ {
57
+ threadMessage: threadMessageTable,
58
+ messageSearchText: messageSearchTextTable,
59
+ },
55
60
  { searchIndex: true },
56
61
  );
57
62
  close = created.close;
58
- repo = createSqliteRepositories({
59
- app: created.db,
60
- mail: created.db,
61
- search: created.db,
62
- }).threadMessage;
63
+ repo = createSqliteRepositories(
64
+ {
65
+ app: created.db,
66
+ mail: created.db,
67
+ search: created.db,
68
+ },
69
+ BETTER_SQLITE3_LIMITS,
70
+ ).threadMessage;
63
71
 
64
72
  await repo.create(
65
73
  makeInput({
@@ -160,10 +168,10 @@ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () =>
160
168
  ]);
161
169
  });
162
170
 
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.
171
+ // A field term is the folded LIKE, and sqlite's lower() folds ASCII only —
172
+ // `é` never matches a stored `CAFÉ`. Applying it anyway would drop a row
173
+ // the caller's own matcher accepts, which is #459 again for that clause
174
+ // shape, so a non-ASCII term narrows nothing.
167
175
  test("drops a short accented term rather than missing the row it should match", async () => {
168
176
  const result = await repo.listByFieldTerms(
169
177
  ACCOUNT,
@@ -1,6 +1,8 @@
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 { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
5
+ import { messageSearchTextTable } from "../schema/message-data.js";
4
6
  import { threadMessageTable } from "../schema/thread-message.js";
5
7
  import { createSqliteRepositories } from "../sqlite-repositories.js";
6
8
  import { createSqliteTestDb } from "../test-db-sqlite.js";
@@ -17,9 +19,9 @@ import {
17
19
  } from "./thread-message.js";
18
20
 
19
21
  // 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.
22
+ // text search — the FTS5 unicode61 index (D4), matching words by prefix, and
23
+ // the folded-LIKE scan for terms with no word. The search contract is accent-
24
+ // and case-insensitive word-prefix match over subject and sender.
23
25
 
24
26
  const ACCOUNT = "acct-thread";
25
27
  const MAILBOX = "mbx-thread";
@@ -55,14 +57,18 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
55
57
  ({ db, sqlite, close } = await createSqliteTestDb(
56
58
  {
57
59
  threadMessage: threadMessageTable,
60
+ messageSearchText: messageSearchTextTable,
58
61
  },
59
62
  { searchIndex: true },
60
63
  ));
61
- repo = createSqliteRepositories({
62
- app: db,
63
- mail: db,
64
- search: db,
65
- }).threadMessage;
64
+ repo = createSqliteRepositories(
65
+ {
66
+ app: db,
67
+ mail: db,
68
+ search: db,
69
+ },
70
+ BETTER_SQLITE3_LIMITS,
71
+ ).threadMessage;
66
72
  });
67
73
 
68
74
  after(async () => {
@@ -115,7 +121,7 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
115
121
  assert.ok(byName.items.some((i) => i.fromName === "Alice Kramer"));
116
122
  });
117
123
 
118
- test("FTS trigram search folds diacritics both ways", async () => {
124
+ test("FTS search folds diacritics both ways", async () => {
119
125
  await repo.create(makeInput({ subject: "Réunion budget" }));
120
126
  const folded = await repo.searchByMailbox(
121
127
  ACCOUNT,
@@ -1,7 +1,12 @@
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 { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
9
+ import { messageSearchTextTable } from "../schema/message-data.js";
5
10
  import { threadMessageTable } from "../schema/thread-message.js";
6
11
  import { createSqliteRepositories } from "../sqlite-repositories.js";
7
12
  import { createSqliteTestDb } from "../test-db-sqlite.js";
@@ -40,11 +45,14 @@ function makeInput(
40
45
  };
41
46
  }
42
47
 
43
- // A real better-sqlite3 database with the FTS5 trigram objects the migrator
48
+ // A real better-sqlite3 database with the FTS5 objects the migrator
44
49
  // installs, so the search predicates run their shipped path.
45
50
  const setupDb = () =>
46
51
  createSqliteTestDb(
47
- { threadMessage: threadMessageTable },
52
+ {
53
+ threadMessage: threadMessageTable,
54
+ messageSearchText: messageSearchTextTable,
55
+ },
48
56
  { searchIndex: true },
49
57
  );
50
58
 
@@ -87,11 +95,14 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
87
95
  before(async () => {
88
96
  const harness = await setupDb();
89
97
  close = harness.close;
90
- repo = createSqliteRepositories({
91
- app: harness.db,
92
- mail: harness.db,
93
- search: harness.db,
94
- }).threadMessage;
98
+ repo = createSqliteRepositories(
99
+ {
100
+ app: harness.db,
101
+ mail: harness.db,
102
+ search: harness.db,
103
+ },
104
+ BETTER_SQLITE3_LIMITS,
105
+ ).threadMessage;
95
106
  });
96
107
 
97
108
  after(async () => {
@@ -194,7 +205,7 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
194
205
  });
195
206
 
196
207
  // ── Scenario 4 ────────────────────────────────────────────────────────────
197
- // Matching runs over the WHOLE mailbox via the trigram index, so an old
208
+ // Matching runs over the WHOLE mailbox via the full-text index, so an old
198
209
  // match well behind the recent rows is still found and paged (the DynamoDB
199
210
  // recent-window bound does not apply here — the improvement over #443 on the
200
211
  // DDB path).
@@ -386,17 +397,23 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
386
397
 
387
398
  describe("DrizzleThreadMessageRepository.searchByDate", () => {
388
399
  let repo: DrizzleThreadMessageRepository;
400
+ let messages: IMessageRepository;
389
401
  let close: () => Promise<void>;
390
402
  const cleanup: Array<() => Promise<void>> = [];
391
403
 
392
404
  before(async () => {
393
405
  const harness = await setupDb();
394
406
  close = harness.close;
395
- repo = createSqliteRepositories({
396
- app: harness.db,
397
- mail: harness.db,
398
- search: harness.db,
399
- }).threadMessage;
407
+ const repositories = createSqliteRepositories(
408
+ {
409
+ app: harness.db,
410
+ mail: harness.db,
411
+ search: harness.db,
412
+ },
413
+ BETTER_SQLITE3_LIMITS,
414
+ );
415
+ repo = repositories.threadMessage;
416
+ messages = repositories.message;
400
417
  });
401
418
 
402
419
  after(async () => {
@@ -412,9 +429,11 @@ describe("DrizzleThreadMessageRepository.searchByDate", () => {
412
429
  rows: Array<Partial<CreateThreadMessageInput>>,
413
430
  ): Promise<void> {
414
431
  for (const overrides of rows) {
415
- const created = await repo.create(
416
- makeInput(accountConfigId, mailboxId, overrides),
417
- );
432
+ const input = makeInput(accountConfigId, mailboxId, overrides);
433
+ if (overrides.snippet) {
434
+ await messages.setSearchText(input.messageId, overrides.snippet);
435
+ }
436
+ const created = await repo.create(input);
418
437
  cleanup.push(() => repo.delete(accountConfigId, created.threadMessageId));
419
438
  }
420
439
  }
@@ -923,11 +942,14 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
923
942
  before(async () => {
924
943
  const harness = await setupDb();
925
944
  close = harness.close;
926
- repo = createSqliteRepositories({
927
- app: harness.db,
928
- mail: harness.db,
929
- search: harness.db,
930
- }).threadMessage;
945
+ repo = createSqliteRepositories(
946
+ {
947
+ app: harness.db,
948
+ mail: harness.db,
949
+ search: harness.db,
950
+ },
951
+ BETTER_SQLITE3_LIMITS,
952
+ ).threadMessage;
931
953
  });
932
954
 
933
955
  after(async () => {
@@ -1053,7 +1075,7 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
1053
1075
  assert.deepEqual(subjectsOf(res), ["shared token here"]);
1054
1076
  });
1055
1077
 
1056
- test("LIKE metacharacters in the query match literally", async () => {
1078
+ test("LIKE metacharacters in a query with no word match literally", async () => {
1057
1079
  const acct = uuid();
1058
1080
  const mbx = uuid();
1059
1081
  await seed(acct, mbx, [
@@ -1061,7 +1083,7 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
1061
1083
  { subject: "50 percent off" },
1062
1084
  ]);
1063
1085
 
1064
- const res = await repo.searchByMailboxWindow(acct, mbx, { query: "50%" });
1086
+ const res = await repo.searchByMailboxWindow(acct, mbx, { query: "%" });
1065
1087
  assert.deepEqual(subjectsOf(res), ["50% off today"]);
1066
1088
  });
1067
1089
 
@@ -1099,11 +1121,14 @@ describe("DrizzleThreadMessageRepository — core CRUD", () => {
1099
1121
  before(async () => {
1100
1122
  const harness = await setupDb();
1101
1123
  close = harness.close;
1102
- repo = createSqliteRepositories({
1103
- app: harness.db,
1104
- mail: harness.db,
1105
- search: harness.db,
1106
- }).threadMessage;
1124
+ repo = createSqliteRepositories(
1125
+ {
1126
+ app: harness.db,
1127
+ mail: harness.db,
1128
+ search: harness.db,
1129
+ },
1130
+ BETTER_SQLITE3_LIMITS,
1131
+ ).threadMessage;
1107
1132
  });
1108
1133
 
1109
1134
  after(async () => {