@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,199 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { eq } from "drizzle-orm";
4
+ import type { Db } from "../db.js";
5
+ import {
6
+ type MessageDataSchema,
7
+ messageDataSchema,
8
+ outboxTable,
9
+ } from "../schema/message-data.js";
10
+ import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
11
+ import { runInTransaction, serializeSqliteWrites } from "../tx.js";
12
+
13
+ // The RFC 036 D3 hard requirement: every write on the shared better-sqlite3
14
+ // connection routes through the wave-1 serialization, so a plain single
15
+ // statement can never land inside another unit's open SAVEPOINT (the
16
+ // uncommitted-read / rollback hazard named on PR #1310). The db handle repos
17
+ // hold is `serializeSqliteWrites(db)` — unserialized access is impossible by
18
+ // construction. The `outbox` table stands in for any write target here (few
19
+ // required columns, already dialect-cast for the repos).
20
+
21
+ const row = (id: string) => ({
22
+ id,
23
+ messageId: `msg-${id}`,
24
+ event: "message.body_synced",
25
+ payload: { messageId: `msg-${id}` },
26
+ createdAt: new Date(),
27
+ });
28
+
29
+ const nextTick = (): Promise<void> =>
30
+ new Promise((resolve) => setImmediate(resolve));
31
+
32
+ describe("serialized SQLite writes (RFC 036 D3)", () => {
33
+ let raw: SqliteTestDb<MessageDataSchema>;
34
+ let db: Db<MessageDataSchema>;
35
+ let close: () => Promise<void>;
36
+
37
+ before(async () => {
38
+ ({ db: raw, close } =
39
+ await createSqliteTestDb<MessageDataSchema>(messageDataSchema));
40
+ db = serializeSqliteWrites(raw);
41
+ });
42
+
43
+ after(async () => {
44
+ await close();
45
+ });
46
+
47
+ const byId = (id: string) =>
48
+ raw.select().from(outboxTable).where(eq(outboxTable.id, id));
49
+
50
+ test("a single-statement write does not interleave into an open transaction", async () => {
51
+ const order: string[] = [];
52
+ let release: () => void = () => {};
53
+ const barrier = new Promise<void>((resolve) => {
54
+ release = resolve;
55
+ });
56
+
57
+ // A top-level transaction: insert TX_ROW, park on a barrier (yielding the
58
+ // event loop with the SAVEPOINT open), then throw so the whole unit rolls
59
+ // back.
60
+ const txDone = runInTransaction(db, async (tx) => {
61
+ await tx.insert(outboxTable).values(row("tx-row"));
62
+ order.push("tx-insert");
63
+ await barrier;
64
+ order.push("tx-throw");
65
+ throw new Error("rollback");
66
+ }).catch(() => {
67
+ order.push("tx-rolledback");
68
+ });
69
+
70
+ // Let the transaction reach the barrier before the single write is issued.
71
+ await nextTick();
72
+ await nextTick();
73
+
74
+ const singleWrite = db
75
+ .insert(outboxTable)
76
+ .values(row("single-row"))
77
+ .then(() => {
78
+ order.push("single-committed");
79
+ });
80
+
81
+ // While the transaction holds the write queue, the single write must not
82
+ // have executed — it is queued behind the open unit, not interleaved into
83
+ // its savepoint.
84
+ await nextTick();
85
+ await nextTick();
86
+ assert.equal(
87
+ order.includes("single-committed"),
88
+ false,
89
+ "single write ran while a top-level transaction was open",
90
+ );
91
+
92
+ release();
93
+ await txDone;
94
+ await singleWrite;
95
+
96
+ assert.deepEqual(order, [
97
+ "tx-insert",
98
+ "tx-throw",
99
+ "tx-rolledback",
100
+ "single-committed",
101
+ ]);
102
+
103
+ // The transaction rolled back; the single write committed independently —
104
+ // it was never part of the rolled-back savepoint.
105
+ assert.equal(
106
+ (await byId("tx-row")).length,
107
+ 0,
108
+ "transaction must roll back",
109
+ );
110
+ assert.equal(
111
+ (await byId("single-row")).length,
112
+ 1,
113
+ "single write must commit, not be swallowed by the rolled-back transaction",
114
+ );
115
+ });
116
+
117
+ test("concurrent single-statement writes all commit", async () => {
118
+ const ids = Array.from({ length: 20 }, (_, i) => `w-${i}`);
119
+ await Promise.all(ids.map((id) => db.insert(outboxTable).values(row(id))));
120
+ for (const id of ids) {
121
+ assert.equal((await byId(id)).length, 1, `${id} must be committed`);
122
+ }
123
+ });
124
+
125
+ test("the explicit .run() executor is also serialized, not just await", async () => {
126
+ // A write executed via `.run()` (not `await`) must still go through the
127
+ // queue — the hardened wrapper intercepts every terminal executor.
128
+ const builder = db
129
+ .insert(outboxTable)
130
+ .values(row("run-row")) as unknown as {
131
+ run: () => Promise<unknown>;
132
+ };
133
+ await builder.run();
134
+ assert.equal((await byId("run-row")).length, 1, "run() committed the row");
135
+ });
136
+
137
+ test("the .values() executor is serialized, not run inline", async () => {
138
+ // `values` doubles as the insert chain method and (on a runnable with
139
+ // `.returning()`) a terminal executor. The executor form must queue like
140
+ // every other terminal — issued while a transaction is parked, it must
141
+ // wait for the unit to finish instead of interleaving into its savepoint.
142
+ const order: string[] = [];
143
+ let release: () => void = () => {};
144
+ const barrier = new Promise<void>((resolve) => {
145
+ release = resolve;
146
+ });
147
+
148
+ const txDone = runInTransaction(db, async (tx) => {
149
+ await tx.insert(outboxTable).values(row("values-tx-row"));
150
+ order.push("tx-insert");
151
+ await barrier;
152
+ });
153
+
154
+ await nextTick();
155
+ await nextTick();
156
+
157
+ const runnable = db
158
+ .insert(outboxTable)
159
+ .values(row("values-row"))
160
+ .returning() as unknown as { values: () => Promise<unknown[]> };
161
+ const executorWrite = runnable.values().then(() => {
162
+ order.push("values-committed");
163
+ });
164
+
165
+ await nextTick();
166
+ await nextTick();
167
+ assert.equal(
168
+ order.includes("values-committed"),
169
+ false,
170
+ ".values() executor ran while a top-level transaction was open",
171
+ );
172
+
173
+ release();
174
+ await txDone;
175
+ await executorWrite;
176
+ assert.deepEqual(order, ["tx-insert", "values-committed"]);
177
+ assert.equal((await byId("values-row")).length, 1);
178
+ });
179
+
180
+ test("writes inside a transaction still commit atomically", async () => {
181
+ await runInTransaction(db, async (tx) => {
182
+ await tx.insert(outboxTable).values(row("a"));
183
+ await tx
184
+ .update(outboxTable)
185
+ .set({ event: "message.moved" })
186
+ .where(eq(outboxTable.id, "a"));
187
+ });
188
+ const [committed] = await byId("a");
189
+ assert.equal(committed?.event, "message.moved");
190
+
191
+ await assert.rejects(() =>
192
+ runInTransaction(db, async (tx) => {
193
+ await tx.insert(outboxTable).values(row("b"));
194
+ throw new Error("boom");
195
+ }),
196
+ );
197
+ assert.equal((await byId("b")).length, 0, "transaction rollback drops b");
198
+ });
199
+ });
@@ -0,0 +1,222 @@
1
+ import { rmSync } from "node:fs";
2
+ import { sql } from "drizzle-orm";
3
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
4
+ import { drizzle } from "drizzle-orm/node-postgres";
5
+ import EmbeddedPostgres from "embedded-postgres";
6
+ import {
7
+ type MessageDataSchema,
8
+ messageDataSchema,
9
+ } from "../schema/message-data.js";
10
+
11
+ export type TestDb = NodePgDatabase<MessageDataSchema>;
12
+
13
+ function randomPort(): number {
14
+ return 54500 + Math.floor(Math.random() * 400);
15
+ }
16
+
17
+ export async function createTestDb(): Promise<{
18
+ db: TestDb;
19
+ stop: () => Promise<void>;
20
+ }> {
21
+ const port = randomPort();
22
+ const databaseDir = `/tmp/drizzle-test-pg-${port}-${Date.now()}`;
23
+
24
+ const pg = new EmbeddedPostgres({
25
+ databaseDir,
26
+ user: "test",
27
+ password: "test",
28
+ port,
29
+ persistent: false,
30
+ });
31
+
32
+ await pg.initialise();
33
+ await pg.start();
34
+
35
+ const connectionString = `postgresql://test:test@localhost:${port}/postgres`;
36
+ const db = drizzle(connectionString, { schema: messageDataSchema }) as TestDb;
37
+
38
+ await db.execute(sql.raw(DDL));
39
+
40
+ return {
41
+ db,
42
+ stop: async () => {
43
+ const client = (
44
+ db as unknown as { $client: { end: () => Promise<void> } }
45
+ ).$client;
46
+ await client.end();
47
+ await pg.stop();
48
+ try {
49
+ rmSync(databaseDir, { recursive: true, force: true });
50
+ } catch {
51
+ // best-effort cleanup
52
+ }
53
+ },
54
+ };
55
+ }
56
+
57
+ const DDL = `
58
+ CREATE TABLE IF NOT EXISTS envelope (
59
+ envelope_id UUID PRIMARY KEY,
60
+ message_id UUID NOT NULL,
61
+ date_value BIGINT NOT NULL,
62
+ date_raw TEXT NOT NULL,
63
+ subject TEXT,
64
+ message_id_value TEXT,
65
+ created_at BIGINT NOT NULL,
66
+ updated_at BIGINT NOT NULL
67
+ );
68
+ CREATE INDEX IF NOT EXISTS envelope_message_id_idx ON envelope (message_id);
69
+
70
+ CREATE TABLE IF NOT EXISTS message_reference (
71
+ message_reference_id UUID PRIMARY KEY,
72
+ message_id UUID NOT NULL,
73
+ envelope_id UUID NOT NULL,
74
+ message_id_value TEXT NOT NULL,
75
+ reference_type TEXT NOT NULL,
76
+ reference_order INTEGER NOT NULL,
77
+ created_at BIGINT NOT NULL,
78
+ updated_at BIGINT NOT NULL
79
+ );
80
+ CREATE INDEX IF NOT EXISTS message_reference_message_id_idx ON message_reference (message_id);
81
+
82
+ CREATE TABLE IF NOT EXISTS envelope_address (
83
+ envelope_address_id UUID PRIMARY KEY,
84
+ message_id UUID NOT NULL,
85
+ address_id UUID NOT NULL,
86
+ display_name TEXT,
87
+ normalized_email TEXT NOT NULL,
88
+ address_role TEXT NOT NULL,
89
+ address_order INTEGER NOT NULL,
90
+ created_at BIGINT NOT NULL,
91
+ updated_at BIGINT NOT NULL
92
+ );
93
+ CREATE INDEX IF NOT EXISTS envelope_address_message_id_idx ON envelope_address (message_id);
94
+
95
+ CREATE TABLE IF NOT EXISTS body_part (
96
+ body_part_id UUID PRIMARY KEY,
97
+ message_id UUID NOT NULL,
98
+ parent_body_part_id UUID,
99
+ part_path TEXT NOT NULL,
100
+ media_type TEXT NOT NULL,
101
+ media_subtype TEXT NOT NULL,
102
+ content_id TEXT,
103
+ content_description TEXT,
104
+ transfer_encoding TEXT NOT NULL,
105
+ size_octets INTEGER NOT NULL,
106
+ line_count INTEGER,
107
+ md5_hash TEXT,
108
+ disposition TEXT,
109
+ disposition_filename TEXT,
110
+ language TEXT,
111
+ location TEXT,
112
+ is_multipart BOOLEAN NOT NULL,
113
+ multipart_subtype TEXT,
114
+ created_at BIGINT NOT NULL,
115
+ updated_at BIGINT NOT NULL
116
+ );
117
+ CREATE INDEX IF NOT EXISTS body_part_message_id_idx ON body_part (message_id);
118
+
119
+ CREATE TABLE IF NOT EXISTS body_part_parameter (
120
+ body_part_parameter_id UUID PRIMARY KEY,
121
+ message_id UUID NOT NULL,
122
+ body_part_id UUID NOT NULL,
123
+ parameter_name TEXT NOT NULL,
124
+ parameter_value TEXT NOT NULL,
125
+ created_at BIGINT NOT NULL,
126
+ updated_at BIGINT NOT NULL
127
+ );
128
+ CREATE INDEX IF NOT EXISTS body_part_parameter_message_id_idx ON body_part_parameter (message_id);
129
+
130
+ CREATE TABLE IF NOT EXISTS raw_message_storage (
131
+ raw_storage_id UUID PRIMARY KEY,
132
+ message_id UUID NOT NULL,
133
+ storage_type TEXT NOT NULL,
134
+ storage_location TEXT NOT NULL,
135
+ storage_key TEXT NOT NULL,
136
+ size_bytes INTEGER NOT NULL,
137
+ checksum_sha256 TEXT NOT NULL,
138
+ content_encoding TEXT NOT NULL,
139
+ stored_at BIGINT NOT NULL,
140
+ expires_at BIGINT,
141
+ created_at BIGINT NOT NULL,
142
+ updated_at BIGINT NOT NULL
143
+ );
144
+ CREATE INDEX IF NOT EXISTS raw_message_storage_message_id_idx ON raw_message_storage (message_id);
145
+
146
+ CREATE TABLE IF NOT EXISTS body_part_storage (
147
+ body_part_storage_id UUID PRIMARY KEY,
148
+ message_id UUID NOT NULL,
149
+ body_part_id UUID NOT NULL,
150
+ storage_type TEXT NOT NULL,
151
+ storage_location TEXT NOT NULL,
152
+ storage_key TEXT NOT NULL,
153
+ decoded_size_bytes INTEGER NOT NULL,
154
+ checksum_sha256 TEXT NOT NULL,
155
+ content_encoding TEXT NOT NULL,
156
+ is_deduped BOOLEAN NOT NULL,
157
+ dedup_hash TEXT,
158
+ stored_at BIGINT NOT NULL,
159
+ created_at BIGINT NOT NULL,
160
+ updated_at BIGINT NOT NULL
161
+ );
162
+ CREATE INDEX IF NOT EXISTS body_part_storage_message_id_idx ON body_part_storage (message_id);
163
+
164
+ CREATE TABLE IF NOT EXISTS body_part_content (
165
+ body_part_content_id UUID PRIMARY KEY,
166
+ message_id UUID NOT NULL,
167
+ body_part_id UUID NOT NULL,
168
+ content TEXT NOT NULL,
169
+ content_length INTEGER NOT NULL,
170
+ created_at BIGINT NOT NULL,
171
+ updated_at BIGINT NOT NULL
172
+ );
173
+ CREATE INDEX IF NOT EXISTS body_part_content_message_id_idx ON body_part_content (message_id);
174
+
175
+ CREATE TABLE IF NOT EXISTS message (
176
+ message_id UUID PRIMARY KEY,
177
+ mailbox_id UUID NOT NULL,
178
+ uid INTEGER NOT NULL,
179
+ sequence_number INTEGER NOT NULL,
180
+ rfc822_size INTEGER NOT NULL,
181
+ internal_date BIGINT NOT NULL,
182
+ message_id_header TEXT,
183
+ envelope_id UUID NOT NULL,
184
+ root_body_part_id UUID NOT NULL,
185
+ body_storage_key TEXT,
186
+ status TEXT NOT NULL DEFAULT 'active',
187
+ sync_status TEXT NOT NULL DEFAULT 'pending',
188
+ original_mailbox_id UUID,
189
+ original_uid INTEGER,
190
+ category TEXT NOT NULL DEFAULT 'uncategorized',
191
+ authenticity JSONB,
192
+ auth_result JSONB,
193
+ provider_spam JSONB,
194
+ has_list_unsubscribe BOOLEAN NOT NULL DEFAULT false,
195
+ moved_by_remit BOOLEAN NOT NULL DEFAULT false,
196
+ placement_verdict JSONB,
197
+ created_at BIGINT NOT NULL,
198
+ updated_at BIGINT NOT NULL
199
+ );
200
+ CREATE INDEX IF NOT EXISTS message_mailbox_id_idx ON message (mailbox_id);
201
+
202
+ CREATE TABLE IF NOT EXISTS message_flag (
203
+ message_flag_id UUID PRIMARY KEY,
204
+ message_id UUID NOT NULL,
205
+ flag_name TEXT NOT NULL,
206
+ set_at BIGINT NOT NULL,
207
+ created_at BIGINT NOT NULL,
208
+ updated_at BIGINT NOT NULL
209
+ );
210
+ CREATE INDEX IF NOT EXISTS message_flag_message_id_idx ON message_flag (message_id);
211
+
212
+ CREATE TABLE IF NOT EXISTS outbox (
213
+ id UUID PRIMARY KEY,
214
+ message_id UUID NOT NULL,
215
+ event TEXT NOT NULL,
216
+ payload JSONB NOT NULL,
217
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
218
+ processed_at BIGINT
219
+ );
220
+ CREATE INDEX IF NOT EXISTS outbox_message_id_idx ON outbox (message_id);
221
+ CREATE INDEX IF NOT EXISTS outbox_unprocessed_idx ON outbox (created_at) WHERE processed_at IS NULL;
222
+ `;
@@ -0,0 +1,198 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import type { CreateThreadMessageInput } from "@remit/data-ports";
4
+ import { threadMessageTable } from "../schema/thread-message.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { DrizzleThreadMessageRepository } from "./thread-message.js";
7
+
8
+ // The thread-message repo on sqlite (RFC 036 D1): CRUD, keyset pagination, and
9
+ // text search — the FTS5 trigram index for terms of three characters or more
10
+ // (D4), the folded-LIKE scan for shorter terms. The search contract is accent-
11
+ // and case-insensitive substring match over subject and sender.
12
+
13
+ const ACCOUNT = "acct-thread";
14
+ const MAILBOX = "mbx-thread";
15
+
16
+ function makeInput(
17
+ overrides: Partial<CreateThreadMessageInput> = {},
18
+ ): CreateThreadMessageInput {
19
+ const now = Date.now();
20
+ return {
21
+ accountConfigId: ACCOUNT,
22
+ threadId: `t-${Math.random().toString(36).slice(2)}`,
23
+ messageId: `m-${Math.random().toString(36).slice(2)}`,
24
+ mailboxId: MAILBOX,
25
+ uid: 1,
26
+ referenceOrder: 0,
27
+ internalDate: now,
28
+ sentDate: now,
29
+ isRead: false,
30
+ isDeleted: false,
31
+ hasAttachment: false,
32
+ hasStars: false,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ describe("DrizzleThreadMessageRepository (sqlite)", () => {
38
+ let db: Awaited<ReturnType<typeof createSqliteTestDb>>["db"];
39
+ let close: () => Promise<void>;
40
+ let repo: DrizzleThreadMessageRepository;
41
+
42
+ before(async () => {
43
+ ({ db, close } = await createSqliteTestDb(
44
+ {
45
+ threadMessage: threadMessageTable,
46
+ },
47
+ { searchIndex: true },
48
+ ));
49
+ repo = new DrizzleThreadMessageRepository(db);
50
+ });
51
+
52
+ after(async () => {
53
+ await close();
54
+ });
55
+
56
+ test("create then get round-trips", async () => {
57
+ const created = await repo.create(
58
+ makeInput({ subject: "Hello world", fromEmail: "a@example.com" }),
59
+ );
60
+ const got = await repo.get(ACCOUNT, created.threadMessageId);
61
+ assert.equal(got.subject, "Hello world");
62
+ assert.equal(got.isRead, false);
63
+ });
64
+
65
+ test("search matches a case-insensitive substring of the subject", async () => {
66
+ await repo.create(makeInput({ subject: "Invoice for March" }));
67
+ await repo.create(makeInput({ subject: "unrelated note" }));
68
+
69
+ const result = await repo.searchByMailbox(
70
+ ACCOUNT,
71
+ MAILBOX,
72
+ { subject: "invoice" },
73
+ { count: 50 },
74
+ );
75
+ assert.ok(
76
+ result.items.every((i) => /invoice/i.test(i.subject ?? "")),
77
+ "every match contains the needle",
78
+ );
79
+ assert.ok(
80
+ result.items.some((i) => i.subject === "Invoice for March"),
81
+ "the matching subject is returned",
82
+ );
83
+ });
84
+
85
+ test("search matches the sender fields", async () => {
86
+ await repo.create(
87
+ makeInput({
88
+ subject: "x",
89
+ fromName: "Alice Kramer",
90
+ fromEmail: "alice@corp.test",
91
+ }),
92
+ );
93
+ const byName = await repo.searchByMailbox(
94
+ ACCOUNT,
95
+ MAILBOX,
96
+ { from: "kramer" },
97
+ { count: 50 },
98
+ );
99
+ assert.ok(byName.items.some((i) => i.fromName === "Alice Kramer"));
100
+ });
101
+
102
+ test("FTS trigram search folds diacritics both ways", async () => {
103
+ await repo.create(makeInput({ subject: "Réunion budget" }));
104
+ const folded = await repo.searchByMailbox(
105
+ ACCOUNT,
106
+ MAILBOX,
107
+ { subject: "reunion" },
108
+ { count: 50 },
109
+ );
110
+ assert.ok(
111
+ folded.items.some((i) => i.subject === "Réunion budget"),
112
+ "an unaccented needle matches an accented subject",
113
+ );
114
+ });
115
+
116
+ test("a sub-3-character term falls back to the folded LIKE scan", async () => {
117
+ await repo.create(makeInput({ subject: "Q2 results" }));
118
+ const short = await repo.searchByMailbox(
119
+ ACCOUNT,
120
+ MAILBOX,
121
+ { subject: "q2" },
122
+ { count: 50 },
123
+ );
124
+ assert.ok(
125
+ short.items.some((i) => i.subject === "Q2 results"),
126
+ "a two-character term still matches via LIKE",
127
+ );
128
+ });
129
+
130
+ test("an updated subject is re-indexed by the FTS triggers", async () => {
131
+ const created = await repo.create(makeInput({ subject: "draft proposal" }));
132
+ await repo.update(ACCOUNT, created.threadMessageId, {
133
+ subject: "final proposal",
134
+ });
135
+ const stale = await repo.searchByMailbox(
136
+ ACCOUNT,
137
+ MAILBOX,
138
+ { subject: "draft" },
139
+ { count: 50 },
140
+ );
141
+ assert.ok(
142
+ !stale.items.some((i) => i.threadMessageId === created.threadMessageId),
143
+ "the old subject no longer matches after re-index",
144
+ );
145
+ const fresh = await repo.searchByMailbox(
146
+ ACCOUNT,
147
+ MAILBOX,
148
+ { subject: "final" },
149
+ { count: 50 },
150
+ );
151
+ assert.ok(
152
+ fresh.items.some((i) => i.threadMessageId === created.threadMessageId),
153
+ "the new subject matches after re-index",
154
+ );
155
+ });
156
+
157
+ test("countByMailbox counts matches under the same predicate", async () => {
158
+ const n = await repo.countByMailbox(
159
+ ACCOUNT,
160
+ MAILBOX,
161
+ { subject: "invoice" },
162
+ { limit: 100 },
163
+ );
164
+ assert.ok(n >= 1);
165
+ });
166
+
167
+ test("listByDate paginates with a stable keyset cursor", async () => {
168
+ const acct = "acct-page";
169
+ const base = Date.now();
170
+ for (let i = 0; i < 5; i++) {
171
+ await repo.create(
172
+ makeInput({
173
+ accountConfigId: acct,
174
+ mailboxId: "mbx-page",
175
+ subject: `page ${i}`,
176
+ sentDate: base - i,
177
+ internalDate: base - i,
178
+ }),
179
+ );
180
+ }
181
+
182
+ const first = await repo.listByDate(acct, { limit: 2, order: "desc" });
183
+ assert.equal(first.items.length, 2);
184
+ assert.ok(first.continuationToken);
185
+
186
+ const second = await repo.listByDate(acct, {
187
+ limit: 2,
188
+ order: "desc",
189
+ continuationToken: first.continuationToken,
190
+ });
191
+ assert.equal(second.items.length, 2);
192
+ const firstIds = new Set(first.items.map((i) => i.threadMessageId));
193
+ assert.ok(
194
+ second.items.every((i) => !firstIds.has(i.threadMessageId)),
195
+ "pages do not overlap",
196
+ );
197
+ });
198
+ });