@remit/drizzle-service 0.0.103 → 0.0.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +4 -3
  2. package/src/repos/account.conformance.d1.test.ts +20 -0
  3. package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
  4. package/src/repos/calendar-default.d1.test.ts +4 -0
  5. package/src/repos/calendar-default.sqlite.test.ts +3 -76
  6. package/src/repos/calendar-default.suite.ts +68 -0
  7. package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
  8. package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
  9. package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
  10. package/src/repos/calendar-put.d1.test.ts +4 -0
  11. package/src/repos/calendar-put.sqlite.test.ts +3 -209
  12. package/src/repos/calendar-put.suite.ts +210 -0
  13. package/src/repos/cascade-delete.ts +1 -1
  14. package/src/repos/filter.d1.test.ts +4 -0
  15. package/src/repos/filter.suite.ts +476 -0
  16. package/src/repos/filter.test.ts +3 -472
  17. package/src/repos/i4-account-config.d1.test.ts +4 -0
  18. package/src/repos/i4-account-config.suite.ts +131 -0
  19. package/src/repos/i4-account-config.test.ts +3 -127
  20. package/src/repos/i4-account-export-request.d1.test.ts +4 -0
  21. package/src/repos/i4-account-export-request.suite.ts +44 -0
  22. package/src/repos/i4-account-export-request.test.ts +3 -40
  23. package/src/repos/i4-account-setting.d1.test.ts +4 -0
  24. package/src/repos/i4-account-setting.suite.ts +100 -0
  25. package/src/repos/i4-account-setting.test.ts +3 -93
  26. package/src/repos/label.conformance.d1.test.ts +20 -0
  27. package/src/repos/message.sqlite.test.ts +54 -0
  28. package/src/repos/message.ts +66 -0
  29. package/src/repos/thread-message-body-muted.sqlite.test.ts +23 -19
  30. package/src/repos/thread-message-field-terms.sqlite.test.ts +9 -5
  31. package/src/repos/thread-message.sqlite.test.ts +6 -4
  32. package/src/repos/thread-message.test.ts +23 -11
  33. package/src/repos/thread-message.ts +13 -9
  34. package/src/repos/thread-search-predicates.ts +36 -63
  35. package/src/schema/message-data.ts +3 -0
  36. package/src/schema-sets/mail.ts +1 -0
  37. package/src/search/thread-message-search.sqlite.test.ts +49 -8
  38. package/src/search/thread-message-search.ts +13 -4
  39. package/src/test-d1.ts +40 -0
  40. package/src/test-db-sqlite.ts +1 -1
  41. package/src/test-shipped-sqlite-schema.ts +8 -6
  42. package/src/test-stores.ts +30 -0
@@ -1,128 +1,4 @@
1
- import assert from "node:assert";
2
- import { after, before, describe, test } from "node:test";
3
- import { createSqliteRepositories } from "../sqlite-repositories.js";
4
- import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
- import type { AccountConfigRepo } from "./i4-account-config.js";
1
+ import { openSqliteTestStores } from "../test-stores.js";
2
+ import { accountConfigRepoSuite } from "./i4-account-config.suite.js";
6
3
 
7
- describe("AccountConfigRepo", () => {
8
- let db: TestDb;
9
- let close: () => Promise<void>;
10
- let repo: AccountConfigRepo;
11
-
12
- before(async () => {
13
- ({ db, close } = await createTestDb());
14
- repo = createSqliteRepositories({
15
- app: db,
16
- mail: db,
17
- search: db,
18
- }).accountConfig;
19
- });
20
-
21
- after(async () => {
22
- await close();
23
- });
24
-
25
- test("create and get", async () => {
26
- const userId = randomId();
27
- const cfg = await repo.create({ userId, name: "My Config" });
28
-
29
- assert.ok(cfg.accountConfigId);
30
- assert.equal(cfg.userId, userId);
31
- assert.equal(cfg.name, "My Config");
32
- assert.equal(cfg.state, "active");
33
-
34
- const fetched = await repo.get(cfg.accountConfigId);
35
- assert.equal(fetched.accountConfigId, cfg.accountConfigId);
36
-
37
- await repo.delete(cfg.accountConfigId);
38
- });
39
-
40
- test("batchGet: WHERE id = ANY($1)", async () => {
41
- const userId = randomId();
42
- const c1 = await repo.create({ userId });
43
- const c2 = await repo.create({ userId });
44
-
45
- const results = await repo.get([c1.accountConfigId, c2.accountConfigId]);
46
- assert.equal(results.length, 2);
47
-
48
- await repo.deleteMany([c1.accountConfigId, c2.accountConfigId]);
49
- });
50
-
51
- test("batchGet empty array returns []", async () => {
52
- const results = await repo.get([]);
53
- assert.deepEqual(results, []);
54
- });
55
-
56
- test("get throws for missing config", async () => {
57
- await assert.rejects(repo.get(randomId()), /not found/i);
58
- });
59
-
60
- test("describe assembles collection (accountConfig + account + address)", async () => {
61
- const userId = randomId();
62
- const cfg = await repo.create({ userId });
63
-
64
- const repos = createSqliteRepositories({ app: db, mail: db, search: db });
65
- const accountRepo = repos.account;
66
- const addrRepo = repos.address;
67
-
68
- await accountRepo.create({
69
- accountConfigId: cfg.accountConfigId,
70
- username: "u",
71
- email: "u@test.com",
72
- isActive: true,
73
- imapHost: "imap.test.com",
74
- imapPort: 993,
75
- imapTls: true,
76
- imapStartTls: false,
77
- connectionState: "not_authenticated",
78
- });
79
-
80
- await addrRepo.createAddress({
81
- addressId: randomId(),
82
- accountConfigId: cfg.accountConfigId,
83
- localPart: "u",
84
- domain: "test.com",
85
- normalizedEmail: "u@test.com",
86
- normalizedCompound: "u@test.com:u",
87
- });
88
-
89
- const desc = await repo.describe(cfg.accountConfigId);
90
- assert.equal(desc.accountConfig.length, 1);
91
- assert.equal(desc.account.length, 1);
92
- assert.equal(desc.address.length, 1);
93
-
94
- await repo.delete(cfg.accountConfigId);
95
- });
96
-
97
- test("listAll returns all configs", async () => {
98
- const userId = randomId();
99
- const c1 = await repo.create({ userId });
100
- const c2 = await repo.create({ userId });
101
-
102
- const all = await repo.listAll();
103
- const ids = new Set(all.map((c) => c.accountConfigId));
104
- assert.ok(ids.has(c1.accountConfigId));
105
- assert.ok(ids.has(c2.accountConfigId));
106
-
107
- await repo.deleteMany([c1.accountConfigId, c2.accountConfigId]);
108
- });
109
-
110
- describe("continuation token rejection (#172)", () => {
111
- for (const [label, token] of [
112
- ["an unparseable", "not-a-cursor"],
113
- ["a bare number", Buffer.from("123").toString("base64url")],
114
- ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
115
- ] as const) {
116
- test(`${label} token is rejected as a 400`, async () => {
117
- await assert.rejects(
118
- () => repo.listByUser(randomId(), { continuationToken: token }),
119
- (error: unknown) => {
120
- assert.equal((error as { statusCode?: number }).statusCode, 400);
121
- assert.equal((error as Error).name, "BadRequestError");
122
- return true;
123
- },
124
- );
125
- });
126
- }
127
- });
128
- });
4
+ accountConfigRepoSuite(openSqliteTestStores);
@@ -0,0 +1,4 @@
1
+ import { openD1TestStores } from "../test-d1.js";
2
+ import { accountExportRequestRepoSuite } from "./i4-account-export-request.suite.js";
3
+
4
+ accountExportRequestRepoSuite(openD1TestStores);
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { randomId } from "../id.js";
4
+ import type { OpenTestStores } from "../test-stores.js";
5
+ import { AccountExportRequestRepo } from "./i4-account-export-request.js";
6
+
7
+ export const accountExportRequestRepoSuite = (open: OpenTestStores): void => {
8
+ describe("AccountExportRequestRepo", () => {
9
+ let close: () => Promise<void>;
10
+ let repo: AccountExportRequestRepo;
11
+
12
+ before(async () => {
13
+ const opened = await open();
14
+ close = opened.close;
15
+ repo = new AccountExportRequestRepo(opened.stores.app.db);
16
+ });
17
+
18
+ after(async () => {
19
+ await close();
20
+ });
21
+
22
+ describe("continuation token rejection (#172)", () => {
23
+ for (const [label, token] of [
24
+ ["an unparseable", "not-a-cursor"],
25
+ ["a bare number", Buffer.from("123").toString("base64url")],
26
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
27
+ ] as const) {
28
+ test(`${label} token is rejected as a 400`, async () => {
29
+ await assert.rejects(
30
+ () =>
31
+ repo.listByAccountConfig(randomId(), {
32
+ continuationToken: token,
33
+ }),
34
+ (error: unknown) => {
35
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
36
+ assert.equal((error as Error).name, "BadRequestError");
37
+ return true;
38
+ },
39
+ );
40
+ });
41
+ }
42
+ });
43
+ });
44
+ };
@@ -1,41 +1,4 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
- import { AccountExportRequestRepo } from "./i4-account-export-request.js";
1
+ import { openSqliteTestStores } from "../test-stores.js";
2
+ import { accountExportRequestRepoSuite } from "./i4-account-export-request.suite.js";
5
3
 
6
- describe("AccountExportRequestRepo", () => {
7
- let db: TestDb;
8
- let close: () => Promise<void>;
9
- let repo: AccountExportRequestRepo;
10
-
11
- before(async () => {
12
- ({ db, close } = await createTestDb());
13
- repo = new AccountExportRequestRepo(db as never);
14
- });
15
-
16
- after(async () => {
17
- await close();
18
- });
19
-
20
- describe("continuation token rejection (#172)", () => {
21
- for (const [label, token] of [
22
- ["an unparseable", "not-a-cursor"],
23
- ["a bare number", Buffer.from("123").toString("base64url")],
24
- ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
25
- ] as const) {
26
- test(`${label} token is rejected as a 400`, async () => {
27
- await assert.rejects(
28
- () =>
29
- repo.listByAccountConfig(randomId(), {
30
- continuationToken: token,
31
- }),
32
- (error: unknown) => {
33
- assert.equal((error as { statusCode?: number }).statusCode, 400);
34
- assert.equal((error as Error).name, "BadRequestError");
35
- return true;
36
- },
37
- );
38
- });
39
- }
40
- });
41
- });
4
+ accountExportRequestRepoSuite(openSqliteTestStores);
@@ -0,0 +1,4 @@
1
+ import { openD1TestStores } from "../test-d1.js";
2
+ import { accountSettingRepoSuite } from "./i4-account-setting.suite.js";
3
+
4
+ accountSettingRepoSuite(openD1TestStores);
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { randomId } from "../id.js";
4
+ import type { OpenTestStores } from "../test-stores.js";
5
+ import { AccountSettingRepo } from "./i4-account-setting.js";
6
+
7
+ export const accountSettingRepoSuite = (open: OpenTestStores): void => {
8
+ describe("AccountSettingRepo", () => {
9
+ let close: () => Promise<void>;
10
+ let repo: AccountSettingRepo;
11
+
12
+ before(async () => {
13
+ const opened = await open();
14
+ close = opened.close;
15
+ repo = new AccountSettingRepo(opened.stores.app.db);
16
+ });
17
+
18
+ after(async () => {
19
+ await close();
20
+ });
21
+
22
+ test("upsert and get", async () => {
23
+ const accountConfigId = randomId();
24
+ await repo.upsert({
25
+ accountConfigId,
26
+ name: "Theme",
27
+ value: { kind: "String", value: "dark" },
28
+ });
29
+
30
+ const setting = await repo.get(accountConfigId, "Theme");
31
+ assert.ok(setting);
32
+ assert.equal(setting.name, "Theme");
33
+ assert.deepEqual(setting.value, { kind: "String", value: "dark" });
34
+
35
+ await repo.delete(accountConfigId, "Theme");
36
+ });
37
+
38
+ test("upsert is idempotent (overwrites)", async () => {
39
+ const accountConfigId = randomId();
40
+ await repo.upsert({
41
+ accountConfigId,
42
+ name: "Density",
43
+ value: { kind: "String", value: "compact" },
44
+ });
45
+ await repo.upsert({
46
+ accountConfigId,
47
+ name: "Density",
48
+ value: { kind: "String", value: "comfortable" },
49
+ });
50
+
51
+ const setting = await repo.get(accountConfigId, "Density");
52
+ assert.deepEqual(setting?.value, {
53
+ kind: "String",
54
+ value: "comfortable",
55
+ });
56
+
57
+ await repo.delete(accountConfigId, "Density");
58
+ });
59
+
60
+ test("get returns null when setting absent", async () => {
61
+ const setting = await repo.get(randomId(), "Theme");
62
+ assert.equal(setting, null);
63
+ });
64
+
65
+ test("listByAccountConfig returns all settings ordered by name", async () => {
66
+ const accountConfigId = randomId();
67
+ await repo.upsert({
68
+ accountConfigId,
69
+ name: "Theme",
70
+ value: { kind: "String", value: "dark" },
71
+ });
72
+ await repo.upsert({
73
+ accountConfigId,
74
+ name: "Density",
75
+ value: { kind: "String", value: "compact" },
76
+ });
77
+
78
+ const settings = await repo.listByAccountConfig(accountConfigId);
79
+ assert.equal(settings.length, 2);
80
+ assert.equal(settings[0].name, "Density");
81
+ assert.equal(settings[1].name, "Theme");
82
+
83
+ await repo.delete(accountConfigId, "Theme");
84
+ await repo.delete(accountConfigId, "Density");
85
+ });
86
+
87
+ test("delete removes the setting", async () => {
88
+ const accountConfigId = randomId();
89
+ await repo.upsert({
90
+ accountConfigId,
91
+ name: "Theme",
92
+ value: { kind: "Boolean", value: true },
93
+ });
94
+ await repo.delete(accountConfigId, "Theme");
95
+
96
+ const setting = await repo.get(accountConfigId, "Theme");
97
+ assert.equal(setting, null);
98
+ });
99
+ });
100
+ };
@@ -1,94 +1,4 @@
1
- import assert from "node:assert";
2
- import { after, before, describe, test } from "node:test";
3
- import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
- import { AccountSettingRepo } from "./i4-account-setting.js";
1
+ import { openSqliteTestStores } from "../test-stores.js";
2
+ import { accountSettingRepoSuite } from "./i4-account-setting.suite.js";
5
3
 
6
- describe("AccountSettingRepo", () => {
7
- let db: TestDb;
8
- let close: () => Promise<void>;
9
- let repo: AccountSettingRepo;
10
-
11
- before(async () => {
12
- ({ db, close } = await createTestDb());
13
- repo = new AccountSettingRepo(db as never);
14
- });
15
-
16
- after(async () => {
17
- await close();
18
- });
19
-
20
- test("upsert and get", async () => {
21
- const accountConfigId = randomId();
22
- await repo.upsert({
23
- accountConfigId,
24
- name: "Theme",
25
- value: { kind: "String", value: "dark" },
26
- });
27
-
28
- const setting = await repo.get(accountConfigId, "Theme");
29
- assert.ok(setting);
30
- assert.equal(setting.name, "Theme");
31
- assert.deepEqual(setting.value, { kind: "String", value: "dark" });
32
-
33
- await repo.delete(accountConfigId, "Theme");
34
- });
35
-
36
- test("upsert is idempotent (overwrites)", async () => {
37
- const accountConfigId = randomId();
38
- await repo.upsert({
39
- accountConfigId,
40
- name: "Density",
41
- value: { kind: "String", value: "compact" },
42
- });
43
- await repo.upsert({
44
- accountConfigId,
45
- name: "Density",
46
- value: { kind: "String", value: "comfortable" },
47
- });
48
-
49
- const setting = await repo.get(accountConfigId, "Density");
50
- assert.deepEqual(setting?.value, { kind: "String", value: "comfortable" });
51
-
52
- await repo.delete(accountConfigId, "Density");
53
- });
54
-
55
- test("get returns null when setting absent", async () => {
56
- const setting = await repo.get(randomId(), "Theme");
57
- assert.equal(setting, null);
58
- });
59
-
60
- test("listByAccountConfig returns all settings ordered by name", async () => {
61
- const accountConfigId = randomId();
62
- await repo.upsert({
63
- accountConfigId,
64
- name: "Theme",
65
- value: { kind: "String", value: "dark" },
66
- });
67
- await repo.upsert({
68
- accountConfigId,
69
- name: "Density",
70
- value: { kind: "String", value: "compact" },
71
- });
72
-
73
- const settings = await repo.listByAccountConfig(accountConfigId);
74
- assert.equal(settings.length, 2);
75
- assert.equal(settings[0].name, "Density");
76
- assert.equal(settings[1].name, "Theme");
77
-
78
- await repo.delete(accountConfigId, "Theme");
79
- await repo.delete(accountConfigId, "Density");
80
- });
81
-
82
- test("delete removes the setting", async () => {
83
- const accountConfigId = randomId();
84
- await repo.upsert({
85
- accountConfigId,
86
- name: "Theme",
87
- value: { kind: "Boolean", value: true },
88
- });
89
- await repo.delete(accountConfigId, "Theme");
90
-
91
- const setting = await repo.get(accountConfigId, "Theme");
92
- assert.equal(setting, null);
93
- });
94
- });
4
+ accountSettingRepoSuite(openSqliteTestStores);
@@ -0,0 +1,20 @@
1
+ import { labelRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { openD1TestStores } from "../test-d1.js";
5
+ import { LabelRepo } from "./label.js";
6
+
7
+ let close: (() => Promise<void>) | undefined;
8
+
9
+ labelRepositoryConformance({
10
+ async createRepository() {
11
+ const opened = await openD1TestStores();
12
+ close = opened.close;
13
+ return new LabelRepo(opened.stores.app.db);
14
+ },
15
+ teardown: async () => {
16
+ await close?.();
17
+ },
18
+ makeId: () => randomId(),
19
+ isNotFoundError: (error) => error instanceof NotFoundError,
20
+ });
@@ -279,4 +279,58 @@ describe("DrizzleMessageRepository (sqlite)", () => {
279
279
  await repo.delete(MESSAGE_ID);
280
280
  await assert.rejects(() => repo.get(MESSAGE_ID));
281
281
  });
282
+
283
+ test("listMissingSearchText names stored bodies without search text, after the cursor", async () => {
284
+ const id = (n: number): string => `00000000-0000-0000-3333-00000000000${n}`;
285
+ for (const n of [1, 2, 3, 4]) {
286
+ await repo.create({ ...BASE_INPUT, messageId: id(n), uid: 100 + n });
287
+ }
288
+ for (const n of [1, 2, 4]) {
289
+ await repo.update(id(n), { bodyStorageKey: `file:///bodies/${n}` });
290
+ }
291
+ await repo.setSearchText(id(2), "already indexed");
292
+
293
+ const ours = (rows: Array<{ messageId: string }>): string[] =>
294
+ rows
295
+ .map((row) => row.messageId)
296
+ .filter((messageId) => messageId.includes("-3333-"));
297
+
298
+ assert.deepEqual(ours(await repo.listMissingSearchText("", 100)), [
299
+ id(1),
300
+ id(4),
301
+ ]);
302
+ assert.deepEqual(ours(await repo.listMissingSearchText(id(1), 100)), [
303
+ id(4),
304
+ ]);
305
+ assert.deepEqual(
306
+ await repo
307
+ .listMissingSearchText(id(1), 100)
308
+ .then((rows) => rows.find((row) => row.messageId === id(4))),
309
+ {
310
+ messageId: id(4),
311
+ bodyStorageKey: "file:///bodies/4",
312
+ },
313
+ );
314
+ });
315
+
316
+ test("copySearchText gives a copy the source's search text", async () => {
317
+ const source = "00000000-0000-0000-4444-000000000001";
318
+ const copy = "00000000-0000-0000-4444-000000000002";
319
+ for (const [messageId, uid] of [
320
+ [source, 201],
321
+ [copy, 202],
322
+ ] as const) {
323
+ await repo.create({ ...BASE_INPUT, messageId, uid });
324
+ await repo.update(messageId, { bodyStorageKey: "file:///bodies/shared" });
325
+ }
326
+ await repo.setSearchText(source, "the shared body");
327
+
328
+ await repo.copySearchText(source, copy);
329
+
330
+ const missing = await repo.listMissingSearchText("", 100);
331
+ assert.equal(
332
+ missing.some((row) => row.messageId === copy),
333
+ false,
334
+ );
335
+ });
282
336
  });
@@ -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
  }
@@ -11,16 +11,12 @@ 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
13
  import { addressTable } from "../schema/i4-address.js";
14
+ import { messageSearchTextTable } from "../schema/message-data.js";
14
15
  import { threadMessageTable } from "../schema/thread-message.js";
15
16
  import { createSqliteRepositories } from "../sqlite-repositories.js";
16
17
  import { createSqliteTestDb } from "../test-db-sqlite.js";
17
- import { shippedIndexDdl } from "../test-shipped-sqlite-schema.js";
18
18
  import type { DrizzleThreadMessageRepository } from "./thread-message.js";
19
19
 
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
20
  const ACCOUNT = "acct-seam";
25
21
  const MAILBOX = "mbx-seam";
26
22
  const SCOPE = new Set([MAILBOX]);
@@ -57,10 +53,13 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
57
53
 
58
54
  before(async () => {
59
55
  ({ db, sqlite, close } = await createSqliteTestDb(
60
- { threadMessage: threadMessageTable, address: addressTable },
56
+ {
57
+ threadMessage: threadMessageTable,
58
+ address: addressTable,
59
+ messageSearchText: messageSearchTextTable,
60
+ },
61
61
  { searchIndex: true },
62
62
  ));
63
- sqlite.exec(mutedIndexDdl);
64
63
  repo = createSqliteRepositories({
65
64
  app: db,
66
65
  mail: db,
@@ -86,15 +85,20 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
86
85
  }),
87
86
  );
88
87
  }
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
- );
88
+ const parcel = makeInput({
89
+ subject: "unrelated note",
90
+ fromEmail: "noise@example.com",
91
+ fromName: "Noise",
92
+ snippet: "Your parcel was left with the concierge",
93
+ sentDate: BASE_DATE,
94
+ });
95
+ await db.insert(messageSearchTextTable).values({
96
+ messageId: parcel.messageId,
97
+ text: "Your parcel was left with the concierge",
98
+ createdAt: 0,
99
+ updatedAt: 0,
100
+ });
101
+ await repo.create(parcel);
98
102
  });
99
103
 
100
104
  test("the term reaches the body text, not just subject and From", async () => {
@@ -123,16 +127,16 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
123
127
  assert.equal(result.items.length, 1);
124
128
  });
125
129
 
126
- test("a term under the trigram floor reads the body too", async () => {
130
+ test("a short word prefix reads the body too", async () => {
127
131
  const result = await repo.searchByDate(
128
132
  ACCOUNT,
129
- { query: "ge" },
133
+ { query: "co" },
130
134
  { mailboxIds: SCOPE, limit: 50 },
131
135
  );
132
136
 
133
137
  assert.ok(
134
138
  result.items.some((item) => item.snippet?.includes("concierge")),
135
- "the folded scan matched the body preview",
139
+ "the prefix matched the body preview",
136
140
  );
137
141
  });
138
142