@remit/drizzle-service 0.0.1

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 (82) hide show
  1. package/drizzle.config.ts +15 -0
  2. package/package.json +52 -0
  3. package/src/db.ts +13 -0
  4. package/src/dialect.ts +13 -0
  5. package/src/error.ts +37 -0
  6. package/src/id.ts +50 -0
  7. package/src/index.ts +62 -0
  8. package/src/pagination.ts +29 -0
  9. package/src/repos/cascade-delete.sqlite.test.ts +159 -0
  10. package/src/repos/cascade-delete.test.ts +423 -0
  11. package/src/repos/cascade-delete.ts +219 -0
  12. package/src/repos/envelope.sqlite.test.ts +94 -0
  13. package/src/repos/envelope.test.ts +225 -0
  14. package/src/repos/envelope.ts +342 -0
  15. package/src/repos/filter-anchor.test.ts +131 -0
  16. package/src/repos/filter-anchor.ts +105 -0
  17. package/src/repos/filter.test.ts +279 -0
  18. package/src/repos/filter.ts +253 -0
  19. package/src/repos/i4-account-config.test.ts +105 -0
  20. package/src/repos/i4-account-config.ts +257 -0
  21. package/src/repos/i4-account-export-request.ts +141 -0
  22. package/src/repos/i4-account-setting.test.ts +94 -0
  23. package/src/repos/i4-account-setting.ts +92 -0
  24. package/src/repos/i4-account.test.ts +223 -0
  25. package/src/repos/i4-account.ts +380 -0
  26. package/src/repos/i4-address-wellknown.ts +31 -0
  27. package/src/repos/i4-address.test.ts +358 -0
  28. package/src/repos/i4-address.ts +613 -0
  29. package/src/repos/i4-mailbox-lock.test.ts +368 -0
  30. package/src/repos/i4-mailbox-lock.ts +140 -0
  31. package/src/repos/i4-mailbox-special-use.ts +165 -0
  32. package/src/repos/i4-mailbox.test.ts +188 -0
  33. package/src/repos/i4-mailbox.ts +347 -0
  34. package/src/repos/i4-message-flag-push.test.ts +189 -0
  35. package/src/repos/i4-message-flag-push.ts +173 -0
  36. package/src/repos/i4-message-placement-move.test.ts +135 -0
  37. package/src/repos/i4-message-placement-move.ts +144 -0
  38. package/src/repos/i4-organize-job-request.ts +142 -0
  39. package/src/repos/i4-outbox-message.test.ts +298 -0
  40. package/src/repos/i4-outbox-message.ts +299 -0
  41. package/src/repos/label.conformance.sqlite.test.ts +23 -0
  42. package/src/repos/label.conformance.test.ts +19 -0
  43. package/src/repos/label.ts +125 -0
  44. package/src/repos/mappers.ts +171 -0
  45. package/src/repos/message-flag.ts +162 -0
  46. package/src/repos/message-label.test.ts +110 -0
  47. package/src/repos/message-label.ts +96 -0
  48. package/src/repos/message.sqlite.test.ts +118 -0
  49. package/src/repos/message.test.ts +488 -0
  50. package/src/repos/message.ts +558 -0
  51. package/src/repos/serialized-writes.sqlite.test.ts +199 -0
  52. package/src/repos/test-helpers.ts +222 -0
  53. package/src/repos/thread-message.sqlite.test.ts +198 -0
  54. package/src/repos/thread-message.test.ts +744 -0
  55. package/src/repos/thread-message.ts +832 -0
  56. package/src/repos/thread-search-predicates.ts +79 -0
  57. package/src/repos/unit-of-work.sqlite.test.ts +138 -0
  58. package/src/repos/unit-of-work.test.ts +105 -0
  59. package/src/repos/unit-of-work.ts +31 -0
  60. package/src/schema/active-entities.ts +19 -0
  61. package/src/schema/i4-account-config.ts +4 -0
  62. package/src/schema/i4-account-export-request.ts +3 -0
  63. package/src/schema/i4-account-setting.ts +3 -0
  64. package/src/schema/i4-address.ts +3 -0
  65. package/src/schema/i4-mailbox-lock.ts +3 -0
  66. package/src/schema/i4-mailbox.ts +4 -0
  67. package/src/schema/i4-message-flag-push.ts +3 -0
  68. package/src/schema/i4-message-placement-move.ts +3 -0
  69. package/src/schema/i4-organize-job-request.ts +1 -0
  70. package/src/schema/i4-outbox-message.ts +3 -0
  71. package/src/schema/message-data.ts +44 -0
  72. package/src/schema/outbox.ts +74 -0
  73. package/src/schema/thread-message.ts +3 -0
  74. package/src/schema-full-sqlite.ts +11 -0
  75. package/src/schema-full.ts +16 -0
  76. package/src/schema.ts +28 -0
  77. package/src/sqlite-client.ts +52 -0
  78. package/src/test-db-sqlite.ts +75 -0
  79. package/src/test-db.ts +76 -0
  80. package/src/tx.ts +208 -0
  81. package/src/vps-migrations-drift.test.ts +34 -0
  82. package/tsconfig.json +8 -0
@@ -0,0 +1,744 @@
1
+ import assert from "node:assert";
2
+ import { readFileSync } from "node:fs";
3
+ import { after, before, describe, test } from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { CreateThreadMessageInput } from "@remit/data-ports";
6
+ import { sql } from "drizzle-orm";
7
+ import { drizzle } from "drizzle-orm/node-postgres";
8
+ import shortUuid from "short-uuid";
9
+ import { threadMessageTable } from "../schema/thread-message.js";
10
+ import {
11
+ clampThreadSearchLimit,
12
+ DrizzleThreadMessageRepository,
13
+ THREAD_SEARCH_MAX_LIMIT,
14
+ } from "./thread-message.js";
15
+
16
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
17
+
18
+ const translator = shortUuid.createTranslator(shortUuid.constants.uuid25Base36);
19
+ const uuid = () => translator.generate();
20
+
21
+ const PG_URL =
22
+ process.env.PG_CONNECTION_URL ??
23
+ "postgresql://remit:remit@localhost:5432/remit_test";
24
+
25
+ // These suites connect to a real Postgres (pg_trgm/unaccent search DDL, which
26
+ // embedded-postgres cannot provide), so they run only in the dedicated pg job
27
+ // that provisions Postgres — mirroring the RUN_INTEG_TESTS gate used across the
28
+ // integration suites. The pure-unit describe below always runs.
29
+ const RUN_INTEG = process.env.RUN_INTEG_TESTS === "1";
30
+
31
+ const DDL = `
32
+ CREATE TABLE IF NOT EXISTS thread_message (
33
+ thread_message_id TEXT PRIMARY KEY,
34
+ account_config_id TEXT NOT NULL,
35
+ thread_id TEXT NOT NULL,
36
+ message_id TEXT NOT NULL,
37
+ mailbox_id TEXT NOT NULL,
38
+ uid INTEGER NOT NULL,
39
+ message_id_header TEXT,
40
+ in_reply_to TEXT,
41
+ reference_order INTEGER NOT NULL DEFAULT 0,
42
+ from_email TEXT,
43
+ from_name TEXT,
44
+ subject TEXT,
45
+ internal_date BIGINT NOT NULL,
46
+ sent_date BIGINT NOT NULL,
47
+ is_read BOOLEAN NOT NULL,
48
+ has_attachment BOOLEAN NOT NULL,
49
+ star TEXT NOT NULL DEFAULT 'none',
50
+ has_stars BOOLEAN NOT NULL,
51
+ is_deleted BOOLEAN NOT NULL,
52
+ snippet TEXT,
53
+ created_at BIGINT NOT NULL,
54
+ updated_at BIGINT NOT NULL
55
+ );
56
+ CREATE INDEX IF NOT EXISTS tm_by_date_idx ON thread_message (account_config_id, sent_date);
57
+ CREATE INDEX IF NOT EXISTS tm_by_mailbox_idx ON thread_message (account_config_id, mailbox_id, sent_date);
58
+ CREATE INDEX IF NOT EXISTS tm_by_attachment_idx ON thread_message (account_config_id, has_attachment, sent_date);
59
+ CREATE INDEX IF NOT EXISTS tm_by_starred_idx ON thread_message (account_config_id, has_stars, sent_date);
60
+ CREATE INDEX IF NOT EXISTS tm_by_mailbox_readstatus_idx ON thread_message (account_config_id, mailbox_id, is_read, sent_date);
61
+ CREATE INDEX IF NOT EXISTS tm_by_thread_idx ON thread_message (thread_id, internal_date);
62
+ CREATE INDEX IF NOT EXISTS tm_by_message_idx ON thread_message (message_id);
63
+ `;
64
+
65
+ function makeInput(
66
+ accountConfigId: string,
67
+ mailboxId: string,
68
+ overrides: Partial<CreateThreadMessageInput> = {},
69
+ ): CreateThreadMessageInput {
70
+ const now = Date.now();
71
+ return {
72
+ accountConfigId,
73
+ threadId: uuid(),
74
+ messageId: uuid(),
75
+ mailboxId,
76
+ uid: 1,
77
+ referenceOrder: 0,
78
+ internalDate: now,
79
+ sentDate: now,
80
+ isRead: false,
81
+ isDeleted: false,
82
+ hasAttachment: false,
83
+ hasStars: false,
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ // The trigram search columns/indexes live in an idempotent SQL script applied
89
+ // after `drizzle-kit push` (kept out of the drizzle schema — see the script
90
+ // header). Apply it here too so the test table matches production.
91
+ const SEARCH_DDL = readFileSync(
92
+ fileURLToPath(
93
+ new URL("../../../../npm-scripts/pg-search-index.sql", import.meta.url),
94
+ ),
95
+ "utf8",
96
+ );
97
+
98
+ async function setupDb(): Promise<void> {
99
+ const db = drizzle(PG_URL, { schema: { threadMessage: threadMessageTable } });
100
+ await db.execute(sql.raw(DDL));
101
+ await db.execute(sql.raw(SEARCH_DDL));
102
+ const client = (db as unknown as { $client: { end(): Promise<void> } })
103
+ .$client;
104
+ await client.end();
105
+ }
106
+
107
+ // ─── clampThreadSearchLimit unit tests ───────────────────────────────────────
108
+
109
+ describe("clampThreadSearchLimit", () => {
110
+ test("defaults an absent limit to the cap", () => {
111
+ assert.equal(clampThreadSearchLimit(undefined), THREAD_SEARCH_MAX_LIMIT);
112
+ });
113
+
114
+ test("caps a too-large limit at the max", () => {
115
+ assert.equal(clampThreadSearchLimit(5000), THREAD_SEARCH_MAX_LIMIT);
116
+ });
117
+
118
+ test("passes a within-range limit through", () => {
119
+ assert.equal(clampThreadSearchLimit(200), 200);
120
+ });
121
+
122
+ test("floors a non-positive limit at 1", () => {
123
+ assert.equal(clampThreadSearchLimit(0), 1);
124
+ assert.equal(clampThreadSearchLimit(-10), 1);
125
+ });
126
+
127
+ test("truncates and defaults non-finite input", () => {
128
+ assert.equal(clampThreadSearchLimit(12.9), 12);
129
+ assert.equal(clampThreadSearchLimit(Number.NaN), THREAD_SEARCH_MAX_LIMIT);
130
+ });
131
+ });
132
+
133
+ // ─── searchByMailboxWindow / countByMailbox integration tests ─────────────────
134
+ //
135
+ // These six scenarios mirror the canonical DynamoDB test suite in
136
+ // packages/remit-electrodb-service/src/models/thread-message.test.ts.
137
+
138
+ describe(
139
+ "DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox",
140
+ { skip: !RUN_INTEG },
141
+ () => {
142
+ let repo: DrizzleThreadMessageRepository;
143
+ const cleanup: Array<() => Promise<void>> = [];
144
+
145
+ before(async () => {
146
+ await setupDb();
147
+ repo = new DrizzleThreadMessageRepository(PG_URL);
148
+ });
149
+
150
+ after(async () => {
151
+ for (const fn of cleanup.reverse()) {
152
+ await fn();
153
+ }
154
+ await repo.close();
155
+ });
156
+
157
+ async function seed(
158
+ accountConfigId: string,
159
+ mailboxId: string,
160
+ rows: Array<Partial<CreateThreadMessageInput>>,
161
+ ): Promise<void> {
162
+ for (const overrides of rows) {
163
+ const created = await repo.create(
164
+ makeInput(accountConfigId, mailboxId, overrides),
165
+ );
166
+ cleanup.push(() =>
167
+ repo.delete(accountConfigId, created.threadMessageId),
168
+ );
169
+ }
170
+ }
171
+
172
+ // ── Scenario 1 ────────────────────────────────────────────────────────────
173
+ // The unread boolean narrows results to matching rows only.
174
+ test("unread filter returns only matching rows", async () => {
175
+ const acct = uuid();
176
+ const mbx = uuid();
177
+ await seed(acct, mbx, [
178
+ { isRead: false, subject: "unread one" },
179
+ { isRead: false, subject: "unread two" },
180
+ { isRead: true, subject: "read one" },
181
+ ]);
182
+
183
+ const unread = await repo.searchByMailboxWindow(
184
+ acct,
185
+ mbx,
186
+ { unread: true },
187
+ { excludeDeleted: true },
188
+ );
189
+ assert.equal(unread.items.length, 2);
190
+ assert.ok(unread.items.every((r) => r.isRead === false));
191
+
192
+ const read = await repo.searchByMailboxWindow(
193
+ acct,
194
+ mbx,
195
+ { unread: false },
196
+ { excludeDeleted: true },
197
+ );
198
+ assert.equal(read.items.length, 1);
199
+ assert.equal(read.items[0].subject, "read one");
200
+ });
201
+
202
+ // ── Scenario 2 ────────────────────────────────────────────────────────────
203
+ // subject filter matches a substring of the subject.
204
+ test("subject filter matches a substring", async () => {
205
+ const acct = uuid();
206
+ const mbx = uuid();
207
+ await seed(acct, mbx, [
208
+ { subject: "invoice March" },
209
+ { subject: "invoice April" },
210
+ { subject: "newsletter" },
211
+ ]);
212
+
213
+ const result = await repo.searchByMailboxWindow(
214
+ acct,
215
+ mbx,
216
+ { subject: "invoice" },
217
+ { excludeDeleted: true },
218
+ );
219
+ assert.equal(result.items.length, 2);
220
+ assert.ok(
221
+ result.items.every((r) => r.subject?.includes("invoice") ?? false),
222
+ );
223
+ });
224
+
225
+ // ── Scenario 3 ────────────────────────────────────────────────────────────
226
+ // A full page (limit 2 over 5 rows) yields a cursor to resume from.
227
+ test("a full page yields a continuation cursor", async () => {
228
+ const acct = uuid();
229
+ const mbx = uuid();
230
+ const now = Date.now();
231
+ await seed(
232
+ acct,
233
+ mbx,
234
+ Array.from({ length: 5 }, (_, i) => ({
235
+ subject: `row ${i}`,
236
+ sentDate: now - i,
237
+ internalDate: now - i,
238
+ })),
239
+ );
240
+
241
+ const result = await repo.searchByMailboxWindow(
242
+ acct,
243
+ mbx,
244
+ {},
245
+ { excludeDeleted: true, limit: 2 },
246
+ );
247
+ assert.equal(result.items.length, 2);
248
+ assert.ok(result.continuationToken, "full page must yield a cursor");
249
+ });
250
+
251
+ // ── Scenario 4 ────────────────────────────────────────────────────────────
252
+ // Matching runs over the WHOLE mailbox via the trigram index, so an old
253
+ // match well behind the recent rows is still found and paged (the DynamoDB
254
+ // recent-window bound does not apply on Postgres — the improvement over #443
255
+ // on the DDB path).
256
+ test("an old match behind the recent rows is still found — matching is whole-mailbox, not window-bounded", async () => {
257
+ const acct = uuid();
258
+ const mbx = uuid();
259
+ const now = Date.now();
260
+ // The newest 2 rows do NOT match; the older 4 rows DO — searching "match"
261
+ // (lowercased, as the client sends it) must reach all four.
262
+ const rows = [
263
+ { subject: "recent plain a", sentDate: now, internalDate: now },
264
+ { subject: "recent plain b", sentDate: now - 1, internalDate: now - 1 },
265
+ { subject: "old MATCH c", sentDate: now - 2, internalDate: now - 2 },
266
+ { subject: "old MATCH d", sentDate: now - 3, internalDate: now - 3 },
267
+ { subject: "old MATCH e", sentDate: now - 4, internalDate: now - 4 },
268
+ { subject: "old MATCH f", sentDate: now - 5, internalDate: now - 5 },
269
+ ];
270
+ await seed(acct, mbx, rows);
271
+
272
+ const page1 = await repo.searchByMailboxWindow(
273
+ acct,
274
+ mbx,
275
+ { subject: "match" },
276
+ { excludeDeleted: true, limit: 2 },
277
+ );
278
+ assert.deepEqual(
279
+ page1.items.map((r) => r.subject),
280
+ ["old MATCH c", "old MATCH d"],
281
+ "first page holds the two newest matches, not the recent non-matches",
282
+ );
283
+ assert.ok(
284
+ page1.continuationToken,
285
+ "more matches remain — cursor expected",
286
+ );
287
+
288
+ const page2 = await repo.searchByMailboxWindow(
289
+ acct,
290
+ mbx,
291
+ { subject: "match" },
292
+ {
293
+ excludeDeleted: true,
294
+ limit: 2,
295
+ continuationToken: page1.continuationToken,
296
+ },
297
+ );
298
+ assert.deepEqual(
299
+ page2.items.map((r) => r.subject),
300
+ ["old MATCH e", "old MATCH f"],
301
+ "paging reaches the oldest matches",
302
+ );
303
+
304
+ const count = await repo.countByMailbox(
305
+ acct,
306
+ mbx,
307
+ { subject: "match" },
308
+ { excludeDeleted: true, limit: 2 },
309
+ );
310
+ assert.equal(
311
+ count,
312
+ 2,
313
+ "count is capped at the page limit when more match",
314
+ );
315
+ });
316
+
317
+ // ── Scenario 5 ────────────────────────────────────────────────────────────
318
+ // desc default: the first page holds the newest matches; count equals the
319
+ // page length when more rows match than the limit.
320
+ test("desc page holds the newest matches and count equals items.length", async () => {
321
+ const acct = uuid();
322
+ const mbx = uuid();
323
+ const now = Date.now();
324
+ await seed(
325
+ acct,
326
+ mbx,
327
+ Array.from({ length: 5 }, (_, i) => ({
328
+ subject: `alpha ${i}`,
329
+ sentDate: now - i,
330
+ internalDate: now - i,
331
+ })),
332
+ );
333
+
334
+ const window = await repo.searchByMailboxWindow(
335
+ acct,
336
+ mbx,
337
+ { subject: "alpha" },
338
+ { excludeDeleted: true, limit: 2 },
339
+ );
340
+ assert.equal(window.items.length, 2);
341
+ const dates = window.items.map((r) => r.sentDate).sort((a, b) => b - a);
342
+ assert.deepEqual(dates, [now, now - 1], "page holds the two newest rows");
343
+
344
+ const count = await repo.countByMailbox(
345
+ acct,
346
+ mbx,
347
+ { subject: "alpha" },
348
+ { excludeDeleted: true, limit: 2 },
349
+ );
350
+ assert.equal(count, window.items.length, "count == items.length");
351
+ assert.equal(count, 2);
352
+ });
353
+
354
+ // ── Scenario 6 ────────────────────────────────────────────────────────────
355
+ // order:asc returns the OLDEST matches first; count agrees with the page.
356
+ test("order:asc returns the oldest matches first", async () => {
357
+ const acct = uuid();
358
+ const mbx = uuid();
359
+ const now = Date.now();
360
+ await seed(
361
+ acct,
362
+ mbx,
363
+ Array.from({ length: 5 }, (_, i) => ({
364
+ subject: `beta ${i}`,
365
+ sentDate: now - i,
366
+ internalDate: now - i,
367
+ })),
368
+ );
369
+
370
+ const window = await repo.searchByMailboxWindow(
371
+ acct,
372
+ mbx,
373
+ { subject: "beta" },
374
+ { excludeDeleted: true, limit: 2, order: "asc" },
375
+ );
376
+ assert.equal(window.items.length, 2);
377
+ const dates = window.items.map((r) => r.sentDate).sort((a, b) => a - b);
378
+ assert.deepEqual(
379
+ dates,
380
+ [now - 4, now - 3],
381
+ "asc page holds the two oldest rows",
382
+ );
383
+
384
+ const count = await repo.countByMailbox(
385
+ acct,
386
+ mbx,
387
+ { subject: "beta" },
388
+ { excludeDeleted: true, limit: 2, order: "asc" },
389
+ );
390
+ assert.equal(count, window.items.length, "count == items.length for asc");
391
+ assert.equal(count, 2);
392
+ });
393
+ },
394
+ );
395
+
396
+ // ─── Native text-search semantics ─────────────────────────────────────────────
397
+ // The type-ahead search box lowercases the query before sending it. These tests
398
+ // pin the Postgres-native behaviour: case- and accent-insensitive substring
399
+ // matching over the whole mailbox, scoped to the account/mailbox.
400
+
401
+ describe(
402
+ "DrizzleThreadMessageRepository — native text search",
403
+ { skip: !RUN_INTEG },
404
+ () => {
405
+ let repo: DrizzleThreadMessageRepository;
406
+ const cleanup: Array<() => Promise<void>> = [];
407
+
408
+ before(async () => {
409
+ await setupDb();
410
+ repo = new DrizzleThreadMessageRepository(PG_URL);
411
+ });
412
+
413
+ after(async () => {
414
+ for (const fn of cleanup.reverse()) {
415
+ await fn();
416
+ }
417
+ await repo.close();
418
+ });
419
+
420
+ async function seed(
421
+ accountConfigId: string,
422
+ mailboxId: string,
423
+ rows: Array<Partial<CreateThreadMessageInput>>,
424
+ ): Promise<void> {
425
+ for (const overrides of rows) {
426
+ const created = await repo.create(
427
+ makeInput(accountConfigId, mailboxId, overrides),
428
+ );
429
+ cleanup.push(() =>
430
+ repo.delete(accountConfigId, created.threadMessageId),
431
+ );
432
+ }
433
+ }
434
+
435
+ const subjectsOf = (r: { items: Array<{ subject?: string }> }) =>
436
+ r.items.map((i) => i.subject).sort();
437
+
438
+ test("subject match is case-insensitive (client lowercases the query)", async () => {
439
+ const acct = uuid();
440
+ const mbx = uuid();
441
+ await seed(acct, mbx, [
442
+ { subject: "Quarterly Invoice" },
443
+ { subject: "invoice reminder" },
444
+ { subject: "Newsletter" },
445
+ ]);
446
+
447
+ const res = await repo.searchByMailboxWindow(acct, mbx, {
448
+ query: "invoice",
449
+ });
450
+ assert.deepEqual(subjectsOf(res), [
451
+ "Quarterly Invoice",
452
+ "invoice reminder",
453
+ ]);
454
+ });
455
+
456
+ test("sender name match is case-insensitive and accent-insensitive", async () => {
457
+ const acct = uuid();
458
+ const mbx = uuid();
459
+ await seed(acct, mbx, [
460
+ { fromName: "Angélique Müller", subject: "a" },
461
+ { fromName: "Someone Else", subject: "b" },
462
+ ]);
463
+
464
+ const accented = await repo.searchByMailboxWindow(acct, mbx, {
465
+ query: "angelique",
466
+ });
467
+ assert.deepEqual(
468
+ subjectsOf(accented),
469
+ ["a"],
470
+ "accent-folded name matches",
471
+ );
472
+
473
+ const upper = await repo.searchByMailboxWindow(acct, mbx, {
474
+ query: "muller",
475
+ });
476
+ assert.deepEqual(subjectsOf(upper), ["a"], "case-folded name matches");
477
+ });
478
+
479
+ test("from filter matches the email address as well as the display name", async () => {
480
+ const acct = uuid();
481
+ const mbx = uuid();
482
+ await seed(acct, mbx, [
483
+ { fromName: "Jane Doe", fromEmail: "jane@acme.example", subject: "a" },
484
+ { fromName: "Bob", fromEmail: "bob@other.example", subject: "b" },
485
+ ]);
486
+
487
+ const byDomain = await repo.searchByMailboxWindow(acct, mbx, {
488
+ from: "acme",
489
+ });
490
+ assert.deepEqual(subjectsOf(byDomain), ["a"], "matches the email domain");
491
+ });
492
+
493
+ test("multi-word query ANDs tokens across the subject and from fields", async () => {
494
+ const acct = uuid();
495
+ const mbx = uuid();
496
+ await seed(acct, mbx, [
497
+ { fromName: "Emma Stone", subject: "Sleep schedule" },
498
+ { fromName: "Emma Watson", subject: "Travel plans" },
499
+ { subject: "Emma Sleep mattress order" },
500
+ ]);
501
+
502
+ // "emma sleep": each token must appear in subject OR from — row 1 (Emma in
503
+ // from, Sleep in subject) and row 3 (both in subject) match; row 2 does not.
504
+ const res = await repo.searchByMailboxWindow(acct, mbx, {
505
+ query: "emma sleep",
506
+ });
507
+ assert.deepEqual(subjectsOf(res), [
508
+ "Emma Sleep mattress order",
509
+ "Sleep schedule",
510
+ ]);
511
+ });
512
+
513
+ test("subject filter does not match text that only appears in the from fields", async () => {
514
+ const acct = uuid();
515
+ const mbx = uuid();
516
+ await seed(acct, mbx, [
517
+ { fromName: "Marketing", subject: "Weekly digest" },
518
+ { subject: "Marketing update" },
519
+ ]);
520
+
521
+ const res = await repo.searchByMailboxWindow(acct, mbx, {
522
+ subject: "marketing",
523
+ });
524
+ assert.deepEqual(subjectsOf(res), ["Marketing update"]);
525
+ });
526
+
527
+ test("results stay scoped to the account and mailbox", async () => {
528
+ const acctA = uuid();
529
+ const acctB = uuid();
530
+ const mbx1 = uuid();
531
+ const mbx2 = uuid();
532
+ await seed(acctA, mbx1, [{ subject: "shared token here" }]);
533
+ await seed(acctB, mbx1, [{ subject: "shared token elsewhere" }]);
534
+ await seed(acctA, mbx2, [{ subject: "shared token other mailbox" }]);
535
+
536
+ const res = await repo.searchByMailboxWindow(acctA, mbx1, {
537
+ query: "shared",
538
+ });
539
+ assert.deepEqual(subjectsOf(res), ["shared token here"]);
540
+ });
541
+
542
+ test("LIKE metacharacters in the query match literally", async () => {
543
+ const acct = uuid();
544
+ const mbx = uuid();
545
+ await seed(acct, mbx, [
546
+ { subject: "50% off today" },
547
+ { subject: "50 percent off" },
548
+ ]);
549
+
550
+ const res = await repo.searchByMailboxWindow(acct, mbx, { query: "50%" });
551
+ assert.deepEqual(subjectsOf(res), ["50% off today"]);
552
+ });
553
+
554
+ test("whole-mailbox recall: a match far behind the recent rows is found", async () => {
555
+ const acct = uuid();
556
+ const mbx = uuid();
557
+ const now = Date.now();
558
+ const rows = Array.from({ length: 40 }, (_, i) => ({
559
+ subject: i === 0 ? "needle in the haystack" : `filler ${i}`,
560
+ sentDate: now - i,
561
+ internalDate: now - i,
562
+ }));
563
+ // Put the only match at the OLDEST position.
564
+ rows[0].subject = "filler 0";
565
+ rows[39].subject = "needle in the haystack";
566
+ await seed(acct, mbx, rows);
567
+
568
+ const res = await repo.searchByMailboxWindow(
569
+ acct,
570
+ mbx,
571
+ { query: "needle" },
572
+ { limit: 5 },
573
+ );
574
+ assert.deepEqual(subjectsOf(res), ["needle in the haystack"]);
575
+ });
576
+ },
577
+ );
578
+
579
+ // ─── Smoke tests for the full interface ──────────────────────────────────────
580
+
581
+ describe(
582
+ "DrizzleThreadMessageRepository — core CRUD",
583
+ { skip: !RUN_INTEG },
584
+ () => {
585
+ let repo: DrizzleThreadMessageRepository;
586
+ const cleanup: Array<() => Promise<void>> = [];
587
+
588
+ before(async () => {
589
+ await setupDb();
590
+ repo = new DrizzleThreadMessageRepository(PG_URL);
591
+ });
592
+
593
+ after(async () => {
594
+ for (const fn of cleanup.reverse()) {
595
+ await fn();
596
+ }
597
+ await repo.close();
598
+ });
599
+
600
+ test("create and get round-trip", async () => {
601
+ const acct = uuid();
602
+ const mbx = uuid();
603
+ const created = await repo.create(
604
+ makeInput(acct, mbx, { subject: "hello" }),
605
+ );
606
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
607
+ assert.ok(created.threadMessageId);
608
+ assert.equal(created.subject, "hello");
609
+
610
+ const fetched = await repo.get(acct, created.threadMessageId);
611
+ assert.equal(fetched.threadMessageId, created.threadMessageId);
612
+ });
613
+
614
+ test("update persists", async () => {
615
+ const acct = uuid();
616
+ const mbx = uuid();
617
+ const created = await repo.create(
618
+ makeInput(acct, mbx, { subject: "original" }),
619
+ );
620
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
621
+
622
+ const updated = await repo.update(acct, created.threadMessageId, {
623
+ subject: "updated",
624
+ });
625
+ assert.equal(updated.subject, "updated");
626
+ });
627
+
628
+ test("delete removes the row", async () => {
629
+ const acct = uuid();
630
+ const mbx = uuid();
631
+ const created = await repo.create(makeInput(acct, mbx));
632
+ await repo.delete(acct, created.threadMessageId);
633
+ await assert.rejects(
634
+ () => repo.get(acct, created.threadMessageId),
635
+ /not found/i,
636
+ );
637
+ });
638
+
639
+ test("listByMailbox returns desc-ordered rows for the mailbox", async () => {
640
+ const acct = uuid();
641
+ const mbx = uuid();
642
+ const now = Date.now();
643
+ const a = await repo.create(
644
+ makeInput(acct, mbx, {
645
+ sentDate: now - 1000,
646
+ internalDate: now - 1000,
647
+ subject: "older",
648
+ }),
649
+ );
650
+ cleanup.push(() => repo.delete(acct, a.threadMessageId));
651
+ const b = await repo.create(
652
+ makeInput(acct, mbx, {
653
+ sentDate: now,
654
+ internalDate: now,
655
+ subject: "newer",
656
+ }),
657
+ );
658
+ cleanup.push(() => repo.delete(acct, b.threadMessageId));
659
+
660
+ const result = await repo.listByMailbox(acct, mbx);
661
+ assert.equal(result.items.length, 2);
662
+ assert.equal(result.items[0].subject, "newer");
663
+ assert.equal(result.items[1].subject, "older");
664
+ });
665
+
666
+ test("get with array returns all matched rows", async () => {
667
+ const acct = uuid();
668
+ const mbx = uuid();
669
+ const a = await repo.create(makeInput(acct, mbx));
670
+ cleanup.push(() => repo.delete(acct, a.threadMessageId));
671
+ const b = await repo.create(makeInput(acct, mbx));
672
+ cleanup.push(() => repo.delete(acct, b.threadMessageId));
673
+
674
+ const rows = await repo.get(acct, [a.threadMessageId, b.threadMessageId]);
675
+ assert.equal(rows.length, 2);
676
+ });
677
+
678
+ // ── Tenant scoping on the id-only lookups (issue #1193) ────────────────────
679
+ // findByMessageId / findAllByMessageId / getByMessageId / countByThread carry
680
+ // accountConfigId in the WHERE clause, so a row created under account A must
681
+ // never resolve under account B's scope.
682
+
683
+ test("findByMessageId is scoped to accountConfigId", async () => {
684
+ const acct = uuid();
685
+ const other = uuid();
686
+ const mbx = uuid();
687
+ const messageId = uuid();
688
+ const created = await repo.create(makeInput(acct, mbx, { messageId }));
689
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
690
+
691
+ const own = await repo.findByMessageId(acct, messageId);
692
+ assert.ok(own);
693
+ assert.equal(own.threadMessageId, created.threadMessageId);
694
+
695
+ const foreign = await repo.findByMessageId(other, messageId);
696
+ assert.equal(foreign, null);
697
+ });
698
+
699
+ test("findAllByMessageId is scoped to accountConfigId", async () => {
700
+ const acct = uuid();
701
+ const other = uuid();
702
+ const messageId = uuid();
703
+ // Same messageId across two mailboxes of the same account.
704
+ const a = await repo.create(makeInput(acct, uuid(), { messageId }));
705
+ cleanup.push(() => repo.delete(acct, a.threadMessageId));
706
+ const b = await repo.create(makeInput(acct, uuid(), { messageId }));
707
+ cleanup.push(() => repo.delete(acct, b.threadMessageId));
708
+
709
+ const own = await repo.findAllByMessageId(acct, messageId);
710
+ assert.equal(own.length, 2);
711
+
712
+ const foreign = await repo.findAllByMessageId(other, messageId);
713
+ assert.equal(foreign.length, 0);
714
+ });
715
+
716
+ test("getByMessageId throws for another account's messageId", async () => {
717
+ const acct = uuid();
718
+ const other = uuid();
719
+ const mbx = uuid();
720
+ const messageId = uuid();
721
+ const created = await repo.create(makeInput(acct, mbx, { messageId }));
722
+ cleanup.push(() => repo.delete(acct, created.threadMessageId));
723
+
724
+ const own = await repo.getByMessageId(acct, messageId);
725
+ assert.equal(own.threadMessageId, created.threadMessageId);
726
+
727
+ await assert.rejects(() => repo.getByMessageId(other, messageId));
728
+ });
729
+
730
+ test("countByThread is scoped to accountConfigId", async () => {
731
+ const acct = uuid();
732
+ const other = uuid();
733
+ const mbx = uuid();
734
+ const threadId = uuid();
735
+ for (let i = 0; i < 3; i++) {
736
+ const row = await repo.create(makeInput(acct, mbx, { threadId }));
737
+ cleanup.push(() => repo.delete(acct, row.threadMessageId));
738
+ }
739
+
740
+ assert.equal(await repo.countByThread(acct, threadId), 3);
741
+ assert.equal(await repo.countByThread(other, threadId), 0);
742
+ });
743
+ },
744
+ );