@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,832 @@
1
+ import type {
2
+ CreateThreadMessageInput,
3
+ IThreadMessageRepository,
4
+ ResultList,
5
+ SearchOptions,
6
+ ThreadMessageItem,
7
+ UpdateThreadMessageInput,
8
+ } from "@remit/data-ports";
9
+ import {
10
+ and,
11
+ asc,
12
+ desc,
13
+ eq,
14
+ gt,
15
+ inArray,
16
+ lt,
17
+ or,
18
+ type SQL,
19
+ sql,
20
+ } from "drizzle-orm";
21
+ import { drizzle } from "drizzle-orm/node-postgres";
22
+ import shortUuid from "short-uuid";
23
+ import { v5 as uuidv5 } from "uuid";
24
+ import type { Db } from "../db.js";
25
+ import { NotFoundError } from "../error.js";
26
+ import { threadMessageTable } from "../schema/thread-message.js";
27
+ import { fromMatch, subjectMatch } from "./thread-search-predicates.js";
28
+
29
+ // ─── ID generation (mirrors remit-electrodb-service/src/id.ts) ───────────────
30
+
31
+ const REMIT_NAMESPACE = "9e89694d-214b-4d9b-99f5-214b4d9b99f5";
32
+ const translator = shortUuid.createTranslator(shortUuid.constants.uuid25Base36);
33
+
34
+ const base36uuidv5 = (name: string): string =>
35
+ translator.fromUUID(uuidv5(name, REMIT_NAMESPACE));
36
+
37
+ export const deriveThreadMessageId = (
38
+ threadId: string,
39
+ messageId: string,
40
+ ): string => base36uuidv5(`threadmsg:${threadId}:${messageId}`);
41
+
42
+ // ─── Constants ────────────────────────────────────────────────────────────────
43
+
44
+ export const THREAD_SEARCH_MAX_LIMIT = 500;
45
+
46
+ export const clampThreadSearchLimit = (limit?: number): number => {
47
+ if (limit === undefined || !Number.isFinite(limit)) {
48
+ return THREAD_SEARCH_MAX_LIMIT;
49
+ }
50
+ return Math.min(Math.max(Math.trunc(limit), 1), THREAD_SEARCH_MAX_LIMIT);
51
+ };
52
+
53
+ // ─── Cursor ──────────────────────────────────────────────────────────────────
54
+
55
+ type DateCursor = { s: number; id: string };
56
+ type ThreadCursor = { d: number; id: string };
57
+ type AccountCursor = { id: string };
58
+
59
+ function encodeDateCursor(sentDate: number, threadMessageId: string): string {
60
+ return Buffer.from(
61
+ JSON.stringify({ s: sentDate, id: threadMessageId }),
62
+ ).toString("base64");
63
+ }
64
+
65
+ function decodeDateCursor(token: string): DateCursor | null {
66
+ try {
67
+ return JSON.parse(Buffer.from(token, "base64").toString()) as DateCursor;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function encodeThreadCursor(
74
+ internalDate: number,
75
+ threadMessageId: string,
76
+ ): string {
77
+ return Buffer.from(
78
+ JSON.stringify({ d: internalDate, id: threadMessageId }),
79
+ ).toString("base64");
80
+ }
81
+
82
+ function decodeThreadCursor(token: string): ThreadCursor | null {
83
+ try {
84
+ return JSON.parse(Buffer.from(token, "base64").toString()) as ThreadCursor;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function encodeAccountCursor(threadMessageId: string): string {
91
+ return Buffer.from(JSON.stringify({ id: threadMessageId })).toString(
92
+ "base64",
93
+ );
94
+ }
95
+
96
+ function decodeAccountCursor(token: string): AccountCursor | null {
97
+ try {
98
+ return JSON.parse(Buffer.from(token, "base64").toString()) as AccountCursor;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ // ─── Schema ──────────────────────────────────────────────────────────────────
105
+
106
+ const SCHEMA = { threadMessage: threadMessageTable };
107
+ type Row = typeof threadMessageTable.$inferSelect;
108
+
109
+ // ─── Row mapping ─────────────────────────────────────────────────────────────
110
+
111
+ function toItem(row: Row): ThreadMessageItem {
112
+ return {
113
+ threadMessageId: row.threadMessageId,
114
+ accountConfigId: row.accountConfigId,
115
+ threadId: row.threadId,
116
+ messageId: row.messageId,
117
+ mailboxId: row.mailboxId,
118
+ uid: row.uid,
119
+ referenceOrder: row.referenceOrder,
120
+ internalDate: row.internalDate,
121
+ sentDate: row.sentDate,
122
+ isRead: row.isRead,
123
+ hasAttachment: row.hasAttachment,
124
+ star: row.star as ThreadMessageItem["star"],
125
+ hasStars: row.hasStars,
126
+ isDeleted: row.isDeleted,
127
+ category: row.category as ThreadMessageItem["category"],
128
+ createdAt: row.createdAt,
129
+ updatedAt: row.updatedAt,
130
+ ...(row.messageIdHeader !== null
131
+ ? { messageIdHeader: row.messageIdHeader }
132
+ : {}),
133
+ ...(row.inReplyTo !== null ? { inReplyTo: row.inReplyTo } : {}),
134
+ ...(row.fromEmail !== null ? { fromEmail: row.fromEmail } : {}),
135
+ ...(row.fromName !== null ? { fromName: row.fromName } : {}),
136
+ ...(row.subject !== null ? { subject: row.subject } : {}),
137
+ ...(row.snippet !== null ? { snippet: row.snippet } : {}),
138
+ };
139
+ }
140
+
141
+ // ─── Search predicates ────────────────────────────────────────────────────────
142
+
143
+ // The accent-/case-insensitive substring predicates are the one text-search
144
+ // seam that differs by dialect; they live in ./thread-search-predicates.ts
145
+ // (Postgres: unaccent + pg_trgm; SQLite: a folded LIKE fallback, RFC 036 D4).
146
+
147
+ // Translate SearchOptions into SQL conditions: subject/from/query as indexed
148
+ // text predicates, the rest as plain column equalities. A multi-word `query`
149
+ // matches rows where every token appears in the subject or the from fields
150
+ // (AND across tokens, OR across fields) — the same shape as the DynamoDB model.
151
+ function buildSearchConditions(search: SearchOptions): SQL[] {
152
+ const conditions: SQL[] = [];
153
+
154
+ if (search.subject) conditions.push(subjectMatch(search.subject));
155
+ if (search.from) conditions.push(fromMatch(search.from));
156
+
157
+ if (search.query) {
158
+ const tokens = search.query.split(/\s+/).filter(Boolean);
159
+ for (const token of tokens) {
160
+ conditions.push(sql`(${subjectMatch(token)} or ${fromMatch(token)})`);
161
+ }
162
+ }
163
+
164
+ if (search.unread !== undefined) {
165
+ conditions.push(eq(threadMessageTable.isRead, !search.unread));
166
+ }
167
+ if (search.starred !== undefined) {
168
+ conditions.push(eq(threadMessageTable.hasStars, search.starred));
169
+ }
170
+ if (search.attachments !== undefined) {
171
+ conditions.push(eq(threadMessageTable.hasAttachment, search.attachments));
172
+ }
173
+
174
+ return conditions;
175
+ }
176
+
177
+ // Keyset cursor over (sent_date, thread_message_id). `desc` walks newest→oldest,
178
+ // `asc` oldest→newest; the id tiebreak keeps paging stable across equal dates.
179
+ function sentDateCursorCond(
180
+ order: "asc" | "desc",
181
+ cursor: DateCursor | null,
182
+ ): SQL | undefined {
183
+ if (!cursor) return undefined;
184
+ const dateCmp =
185
+ order === "desc"
186
+ ? lt(threadMessageTable.sentDate, cursor.s)
187
+ : gt(threadMessageTable.sentDate, cursor.s);
188
+ return or(
189
+ dateCmp,
190
+ and(
191
+ eq(threadMessageTable.sentDate, cursor.s),
192
+ gt(threadMessageTable.threadMessageId, cursor.id),
193
+ ),
194
+ );
195
+ }
196
+
197
+ // ─── Repository ──────────────────────────────────────────────────────────────
198
+
199
+ export class DrizzleThreadMessageRepository
200
+ implements IThreadMessageRepository
201
+ {
202
+ private db: Db<Record<string, unknown>>;
203
+
204
+ constructor(connectionOrDb: string | Db<Record<string, unknown>>) {
205
+ this.db =
206
+ typeof connectionOrDb === "string"
207
+ ? drizzle(connectionOrDb, { schema: SCHEMA })
208
+ : connectionOrDb;
209
+ }
210
+
211
+ async close(): Promise<void> {
212
+ // The underlying driver differs by dialect: a pg Pool closes with `end()`,
213
+ // a better-sqlite3 Database with `close()`. Feature-detect so a sqlite
214
+ // handle never hits a missing `end()`.
215
+ const client = (this.db as unknown as { $client?: unknown }).$client;
216
+ if (client && typeof (client as { end?: unknown }).end === "function") {
217
+ await (client as { end: () => Promise<void> }).end();
218
+ return;
219
+ }
220
+ if (client && typeof (client as { close?: unknown }).close === "function") {
221
+ (client as { close: () => void }).close();
222
+ }
223
+ }
224
+
225
+ async create(input: CreateThreadMessageInput): Promise<ThreadMessageItem> {
226
+ const now = Date.now();
227
+ const threadMessageId = deriveThreadMessageId(
228
+ input.threadId,
229
+ input.messageId,
230
+ );
231
+ const row = {
232
+ threadMessageId,
233
+ accountConfigId: input.accountConfigId,
234
+ threadId: input.threadId,
235
+ messageId: input.messageId,
236
+ mailboxId: input.mailboxId,
237
+ uid: input.uid,
238
+ referenceOrder: input.referenceOrder,
239
+ internalDate: input.internalDate,
240
+ sentDate: input.sentDate,
241
+ isRead: input.isRead,
242
+ hasAttachment: input.hasAttachment,
243
+ star: input.star ?? "none",
244
+ hasStars: input.hasStars,
245
+ isDeleted: input.isDeleted,
246
+ category: input.category ?? "uncategorized",
247
+ messageIdHeader: input.messageIdHeader ?? null,
248
+ inReplyTo: input.inReplyTo ?? null,
249
+ fromEmail: input.fromEmail ?? null,
250
+ fromName: input.fromName ?? null,
251
+ subject: input.subject ?? null,
252
+ snippet: input.snippet ?? null,
253
+ createdAt: now,
254
+ updatedAt: now,
255
+ };
256
+ const [inserted] = await this.db
257
+ .insert(threadMessageTable)
258
+ .values(row)
259
+ .onConflictDoNothing()
260
+ .returning();
261
+ if (inserted) return toItem(inserted);
262
+
263
+ const [existing] = await this.db
264
+ .select()
265
+ .from(threadMessageTable)
266
+ .where(eq(threadMessageTable.threadMessageId, threadMessageId));
267
+ if (!existing) throw new NotFoundError("ThreadMessage not found");
268
+ return toItem(existing);
269
+ }
270
+
271
+ async get(
272
+ accountConfigId: string,
273
+ threadMessageId: string,
274
+ ): Promise<ThreadMessageItem>;
275
+ async get(
276
+ accountConfigId: string,
277
+ threadMessageIds: string[],
278
+ ): Promise<ThreadMessageItem[]>;
279
+ async get(
280
+ accountConfigId: string,
281
+ threadMessageId: string | string[],
282
+ ): Promise<ThreadMessageItem | ThreadMessageItem[]> {
283
+ if (Array.isArray(threadMessageId)) {
284
+ if (threadMessageId.length === 0) return [];
285
+ const rows = await this.db
286
+ .select()
287
+ .from(threadMessageTable)
288
+ .where(
289
+ and(
290
+ eq(threadMessageTable.accountConfigId, accountConfigId),
291
+ inArray(threadMessageTable.threadMessageId, threadMessageId),
292
+ ),
293
+ );
294
+ return rows.map(toItem);
295
+ }
296
+
297
+ const [row] = await this.db
298
+ .select()
299
+ .from(threadMessageTable)
300
+ .where(
301
+ and(
302
+ eq(threadMessageTable.accountConfigId, accountConfigId),
303
+ eq(threadMessageTable.threadMessageId, threadMessageId),
304
+ ),
305
+ );
306
+ if (!row) throw new NotFoundError("ThreadMessage not found");
307
+ return toItem(row);
308
+ }
309
+
310
+ async update(
311
+ accountConfigId: string,
312
+ threadMessageId: string,
313
+ input: UpdateThreadMessageInput,
314
+ _options?: {
315
+ composites?: {
316
+ sentDate?: number;
317
+ mailboxId?: string;
318
+ isRead?: boolean;
319
+ isDeleted?: boolean;
320
+ hasStars?: boolean;
321
+ hasAttachment?: boolean;
322
+ };
323
+ },
324
+ ): Promise<ThreadMessageItem> {
325
+ const now = Date.now();
326
+ const set: Partial<Row> = { updatedAt: now };
327
+ if (input.mailboxId !== undefined) set.mailboxId = input.mailboxId;
328
+ if (input.uid !== undefined) set.uid = input.uid;
329
+ if (input.isRead !== undefined) set.isRead = input.isRead;
330
+ if (input.isDeleted !== undefined) set.isDeleted = input.isDeleted;
331
+ if (input.hasAttachment !== undefined)
332
+ set.hasAttachment = input.hasAttachment;
333
+ if (input.hasStars !== undefined) set.hasStars = input.hasStars;
334
+ if (input.star !== undefined) set.star = input.star;
335
+ if (input.category !== undefined) set.category = input.category;
336
+ if (input.subject !== undefined) set.subject = input.subject;
337
+ if (input.fromEmail !== undefined) set.fromEmail = input.fromEmail;
338
+ if (input.fromName !== undefined) set.fromName = input.fromName;
339
+ if (input.snippet !== undefined) set.snippet = input.snippet;
340
+ if (input.sentDate !== undefined) set.sentDate = input.sentDate;
341
+ if (input.internalDate !== undefined) set.internalDate = input.internalDate;
342
+ if (input.messageIdHeader !== undefined)
343
+ set.messageIdHeader = input.messageIdHeader;
344
+ if (input.inReplyTo !== undefined) set.inReplyTo = input.inReplyTo;
345
+ if (input.referenceOrder !== undefined)
346
+ set.referenceOrder = input.referenceOrder;
347
+
348
+ const [row] = await this.db
349
+ .update(threadMessageTable)
350
+ .set(set)
351
+ .where(
352
+ and(
353
+ eq(threadMessageTable.accountConfigId, accountConfigId),
354
+ eq(threadMessageTable.threadMessageId, threadMessageId),
355
+ ),
356
+ )
357
+ .returning();
358
+ if (!row) throw new NotFoundError("ThreadMessage not found");
359
+ return toItem(row);
360
+ }
361
+
362
+ async delete(
363
+ accountConfigId: string,
364
+ threadMessageId: string,
365
+ ): Promise<void> {
366
+ await this.db
367
+ .delete(threadMessageTable)
368
+ .where(
369
+ and(
370
+ eq(threadMessageTable.accountConfigId, accountConfigId),
371
+ eq(threadMessageTable.threadMessageId, threadMessageId),
372
+ ),
373
+ );
374
+ }
375
+
376
+ async deleteMany(
377
+ keys: Array<{ accountConfigId: string; threadMessageId: string }>,
378
+ ): Promise<void> {
379
+ if (keys.length === 0) return;
380
+ const accountConfigId = keys[0].accountConfigId;
381
+ const ids = keys.map((k) => k.threadMessageId);
382
+ await this.db
383
+ .delete(threadMessageTable)
384
+ .where(
385
+ and(
386
+ eq(threadMessageTable.accountConfigId, accountConfigId),
387
+ inArray(threadMessageTable.threadMessageId, ids),
388
+ ),
389
+ );
390
+ }
391
+
392
+ async listByAccount(
393
+ accountConfigId: string,
394
+ options?: { limit?: number; continuationToken?: string },
395
+ ): Promise<ResultList<ThreadMessageItem>> {
396
+ const cursor = options?.continuationToken
397
+ ? decodeAccountCursor(options.continuationToken)
398
+ : null;
399
+
400
+ const rows = await this.db
401
+ .select()
402
+ .from(threadMessageTable)
403
+ .where(
404
+ and(
405
+ eq(threadMessageTable.accountConfigId, accountConfigId),
406
+ cursor
407
+ ? gt(threadMessageTable.threadMessageId, cursor.id)
408
+ : undefined,
409
+ ),
410
+ )
411
+ .orderBy(asc(threadMessageTable.threadMessageId))
412
+ .limit(options?.limit ?? 100);
413
+
414
+ const lastRow = rows[rows.length - 1];
415
+ const hasMore =
416
+ options?.limit !== undefined && rows.length === options.limit;
417
+ return {
418
+ items: rows.map(toItem),
419
+ continuationToken:
420
+ hasMore && lastRow
421
+ ? encodeAccountCursor(lastRow.threadMessageId)
422
+ : undefined,
423
+ };
424
+ }
425
+
426
+ async listByDate(
427
+ accountConfigId: string,
428
+ options?: {
429
+ order?: "asc" | "desc";
430
+ limit?: number;
431
+ continuationToken?: string;
432
+ inboxMailboxIds?: Set<string>;
433
+ excludeDeleted?: boolean;
434
+ },
435
+ ): Promise<ResultList<ThreadMessageItem>> {
436
+ const order = options?.order ?? "desc";
437
+ const cursor = options?.continuationToken
438
+ ? decodeDateCursor(options.continuationToken)
439
+ : null;
440
+
441
+ const cursorCond = cursor
442
+ ? order === "desc"
443
+ ? or(
444
+ lt(threadMessageTable.sentDate, cursor.s),
445
+ and(
446
+ eq(threadMessageTable.sentDate, cursor.s),
447
+ gt(threadMessageTable.threadMessageId, cursor.id),
448
+ ),
449
+ )
450
+ : or(
451
+ gt(threadMessageTable.sentDate, cursor.s),
452
+ and(
453
+ eq(threadMessageTable.sentDate, cursor.s),
454
+ gt(threadMessageTable.threadMessageId, cursor.id),
455
+ ),
456
+ )
457
+ : undefined;
458
+
459
+ const mailboxCond = options?.inboxMailboxIds?.size
460
+ ? inArray(threadMessageTable.mailboxId, [...options.inboxMailboxIds])
461
+ : undefined;
462
+
463
+ const rows = await this.db
464
+ .select()
465
+ .from(threadMessageTable)
466
+ .where(
467
+ and(
468
+ eq(threadMessageTable.accountConfigId, accountConfigId),
469
+ mailboxCond,
470
+ options?.excludeDeleted
471
+ ? eq(threadMessageTable.isDeleted, false)
472
+ : undefined,
473
+ cursorCond,
474
+ ),
475
+ )
476
+ .orderBy(
477
+ order === "desc"
478
+ ? desc(threadMessageTable.sentDate)
479
+ : asc(threadMessageTable.sentDate),
480
+ asc(threadMessageTable.threadMessageId),
481
+ )
482
+ .limit(options?.limit ?? 100);
483
+
484
+ const lastRow = rows[rows.length - 1];
485
+ const hasMore =
486
+ options?.limit !== undefined && rows.length === options.limit;
487
+ return {
488
+ items: rows.map(toItem),
489
+ continuationToken:
490
+ hasMore && lastRow
491
+ ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
492
+ : undefined,
493
+ };
494
+ }
495
+
496
+ async listByThread(
497
+ threadId: string,
498
+ accountConfigId: string,
499
+ options?: {
500
+ order?: "asc" | "desc";
501
+ limit?: number;
502
+ continuationToken?: string;
503
+ mailboxId?: string;
504
+ excludeDeleted?: boolean;
505
+ },
506
+ ): Promise<ResultList<ThreadMessageItem>> {
507
+ const order = options?.order ?? "asc";
508
+ const cursor = options?.continuationToken
509
+ ? decodeThreadCursor(options.continuationToken)
510
+ : null;
511
+
512
+ const cursorCond = cursor
513
+ ? order === "asc"
514
+ ? or(
515
+ gt(threadMessageTable.internalDate, cursor.d),
516
+ and(
517
+ eq(threadMessageTable.internalDate, cursor.d),
518
+ gt(threadMessageTable.threadMessageId, cursor.id),
519
+ ),
520
+ )
521
+ : or(
522
+ lt(threadMessageTable.internalDate, cursor.d),
523
+ and(
524
+ eq(threadMessageTable.internalDate, cursor.d),
525
+ gt(threadMessageTable.threadMessageId, cursor.id),
526
+ ),
527
+ )
528
+ : undefined;
529
+
530
+ const rows = await this.db
531
+ .select()
532
+ .from(threadMessageTable)
533
+ .where(
534
+ and(
535
+ eq(threadMessageTable.threadId, threadId),
536
+ eq(threadMessageTable.accountConfigId, accountConfigId),
537
+ options?.mailboxId
538
+ ? eq(threadMessageTable.mailboxId, options.mailboxId)
539
+ : undefined,
540
+ options?.excludeDeleted
541
+ ? eq(threadMessageTable.isDeleted, false)
542
+ : undefined,
543
+ cursorCond,
544
+ ),
545
+ )
546
+ .orderBy(
547
+ order === "asc"
548
+ ? asc(threadMessageTable.internalDate)
549
+ : desc(threadMessageTable.internalDate),
550
+ asc(threadMessageTable.threadMessageId),
551
+ )
552
+ .limit(options?.limit ?? 100);
553
+
554
+ const lastRow = rows[rows.length - 1];
555
+ const hasMore =
556
+ options?.limit !== undefined && rows.length === options.limit;
557
+ return {
558
+ items: rows.map(toItem),
559
+ continuationToken:
560
+ hasMore && lastRow
561
+ ? encodeThreadCursor(lastRow.internalDate, lastRow.threadMessageId)
562
+ : undefined,
563
+ };
564
+ }
565
+
566
+ async findByMessageId(
567
+ accountConfigId: string,
568
+ messageId: string,
569
+ ): Promise<ThreadMessageItem | null> {
570
+ const [row] = await this.db
571
+ .select()
572
+ .from(threadMessageTable)
573
+ .where(
574
+ and(
575
+ eq(threadMessageTable.accountConfigId, accountConfigId),
576
+ eq(threadMessageTable.messageId, messageId),
577
+ ),
578
+ )
579
+ .limit(1);
580
+ return row ? toItem(row) : null;
581
+ }
582
+
583
+ async findAllByMessageId(
584
+ accountConfigId: string,
585
+ messageId: string,
586
+ ): Promise<ThreadMessageItem[]> {
587
+ const rows = await this.db
588
+ .select()
589
+ .from(threadMessageTable)
590
+ .where(
591
+ and(
592
+ eq(threadMessageTable.accountConfigId, accountConfigId),
593
+ eq(threadMessageTable.messageId, messageId),
594
+ ),
595
+ );
596
+ return rows.map(toItem);
597
+ }
598
+
599
+ async getByMessageId(
600
+ accountConfigId: string,
601
+ messageId: string,
602
+ ): Promise<ThreadMessageItem> {
603
+ const item = await this.findByMessageId(accountConfigId, messageId);
604
+ if (!item) throw new NotFoundError("ThreadMessage not found");
605
+ return item;
606
+ }
607
+
608
+ async listByMailbox(
609
+ accountConfigId: string,
610
+ mailboxId: string,
611
+ options?: {
612
+ order?: "asc" | "desc";
613
+ limit?: number;
614
+ continuationToken?: string;
615
+ attributes?: string[];
616
+ excludeDeleted?: boolean;
617
+ },
618
+ ): Promise<ResultList<ThreadMessageItem>> {
619
+ const order = options?.order ?? "desc";
620
+ const cursor = options?.continuationToken
621
+ ? decodeDateCursor(options.continuationToken)
622
+ : null;
623
+
624
+ const rows = await this.db
625
+ .select()
626
+ .from(threadMessageTable)
627
+ .where(
628
+ and(
629
+ eq(threadMessageTable.accountConfigId, accountConfigId),
630
+ eq(threadMessageTable.mailboxId, mailboxId),
631
+ options?.excludeDeleted
632
+ ? eq(threadMessageTable.isDeleted, false)
633
+ : undefined,
634
+ sentDateCursorCond(order, cursor),
635
+ ),
636
+ )
637
+ .orderBy(
638
+ order === "desc"
639
+ ? desc(threadMessageTable.sentDate)
640
+ : asc(threadMessageTable.sentDate),
641
+ asc(threadMessageTable.threadMessageId),
642
+ )
643
+ .limit(options?.limit ?? 100);
644
+
645
+ const lastRow = rows[rows.length - 1];
646
+ const hasMore =
647
+ options?.limit !== undefined && rows.length === options.limit;
648
+ return {
649
+ items: rows.map(toItem),
650
+ continuationToken:
651
+ hasMore && lastRow
652
+ ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
653
+ : undefined,
654
+ };
655
+ }
656
+
657
+ async countByThread(
658
+ accountConfigId: string,
659
+ threadId: string,
660
+ ): Promise<number> {
661
+ const [{ count }] = await this.db
662
+ .select({ count: sql<number>`cast(count(*) as int)` })
663
+ .from(threadMessageTable)
664
+ .where(
665
+ and(
666
+ eq(threadMessageTable.accountConfigId, accountConfigId),
667
+ eq(threadMessageTable.threadId, threadId),
668
+ ),
669
+ );
670
+ return count;
671
+ }
672
+
673
+ async searchByMailbox(
674
+ accountConfigId: string,
675
+ mailboxId: string,
676
+ search: SearchOptions,
677
+ options?: {
678
+ order?: "asc" | "desc";
679
+ count?: number;
680
+ continuationToken?: string;
681
+ excludeDeleted?: boolean;
682
+ },
683
+ ): Promise<ResultList<ThreadMessageItem>> {
684
+ const order = options?.order ?? "desc";
685
+ const limit = options?.count ?? 100;
686
+ const cursor = options?.continuationToken
687
+ ? decodeDateCursor(options.continuationToken)
688
+ : null;
689
+
690
+ const rows = await this.db
691
+ .select()
692
+ .from(threadMessageTable)
693
+ .where(
694
+ and(
695
+ eq(threadMessageTable.accountConfigId, accountConfigId),
696
+ eq(threadMessageTable.mailboxId, mailboxId),
697
+ options?.excludeDeleted
698
+ ? eq(threadMessageTable.isDeleted, false)
699
+ : undefined,
700
+ ...buildSearchConditions(search),
701
+ sentDateCursorCond(order, cursor),
702
+ ),
703
+ )
704
+ .orderBy(
705
+ order === "desc"
706
+ ? desc(threadMessageTable.sentDate)
707
+ : asc(threadMessageTable.sentDate),
708
+ asc(threadMessageTable.threadMessageId),
709
+ )
710
+ .limit(limit);
711
+
712
+ const lastRow = rows[rows.length - 1];
713
+ return {
714
+ items: rows.map(toItem),
715
+ continuationToken:
716
+ rows.length === limit && lastRow
717
+ ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
718
+ : undefined,
719
+ };
720
+ }
721
+
722
+ /**
723
+ * Mailbox search for `searchThreads`. Matching (subject / from / query and
724
+ * the boolean filters) runs in SQL over the whole mailbox via the trigram
725
+ * indexes, ordered by sent_date with an id tiebreak. `limit` is a page size
726
+ * over MATCHES (clamped to THREAD_SEARCH_MAX_LIMIT); the DynamoDB name is
727
+ * kept for interface parity, but there is no recent-window read bound —
728
+ * Postgres indexes the text, so a match anywhere in the mailbox is reachable.
729
+ *
730
+ * Cursor: the last returned row `(sentDate, threadMessageId)`. A full page
731
+ * yields a cursor so callers can resume.
732
+ */
733
+ async searchByMailboxWindow(
734
+ accountConfigId: string,
735
+ mailboxId: string,
736
+ search: SearchOptions,
737
+ options?: {
738
+ order?: "asc" | "desc";
739
+ limit?: number;
740
+ continuationToken?: string;
741
+ attributes?: string[];
742
+ excludeDeleted?: boolean;
743
+ },
744
+ ): Promise<ResultList<ThreadMessageItem>> {
745
+ const order = options?.order ?? "desc";
746
+ const limit = clampThreadSearchLimit(options?.limit);
747
+ const cursor = options?.continuationToken
748
+ ? decodeDateCursor(options.continuationToken)
749
+ : null;
750
+
751
+ const rows = await this.db
752
+ .select()
753
+ .from(threadMessageTable)
754
+ .where(
755
+ and(
756
+ eq(threadMessageTable.accountConfigId, accountConfigId),
757
+ eq(threadMessageTable.mailboxId, mailboxId),
758
+ options?.excludeDeleted
759
+ ? eq(threadMessageTable.isDeleted, false)
760
+ : undefined,
761
+ ...buildSearchConditions(search),
762
+ sentDateCursorCond(order, cursor),
763
+ ),
764
+ )
765
+ .orderBy(
766
+ order === "desc"
767
+ ? desc(threadMessageTable.sentDate)
768
+ : asc(threadMessageTable.sentDate),
769
+ asc(threadMessageTable.threadMessageId),
770
+ )
771
+ .limit(limit);
772
+
773
+ const lastRow = rows[rows.length - 1];
774
+ return {
775
+ items: rows.map(toItem),
776
+ continuationToken:
777
+ rows.length === limit && lastRow
778
+ ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
779
+ : undefined,
780
+ };
781
+ }
782
+
783
+ /**
784
+ * COUNT of matches over the SAME predicate as `searchByMailboxWindow`, capped
785
+ * at the same clamped limit so `count` never exceeds one page — count equals
786
+ * `items.length` whenever both are issued with the same limit.
787
+ */
788
+ async countByMailbox(
789
+ accountConfigId: string,
790
+ mailboxId: string,
791
+ search: SearchOptions,
792
+ options?: {
793
+ limit?: number;
794
+ excludeDeleted?: boolean;
795
+ order?: "asc" | "desc";
796
+ },
797
+ ): Promise<number> {
798
+ const cap = clampThreadSearchLimit(options?.limit);
799
+ const [{ count }] = await this.db
800
+ .select({ count: sql<number>`cast(count(*) as int)` })
801
+ .from(threadMessageTable)
802
+ .where(
803
+ and(
804
+ eq(threadMessageTable.accountConfigId, accountConfigId),
805
+ eq(threadMessageTable.mailboxId, mailboxId),
806
+ options?.excludeDeleted
807
+ ? eq(threadMessageTable.isDeleted, false)
808
+ : undefined,
809
+ ...buildSearchConditions(search),
810
+ ),
811
+ );
812
+ return Math.min(count, cap);
813
+ }
814
+
815
+ async listAllByAccount(
816
+ accountConfigId: string,
817
+ ): Promise<ThreadMessageItem[]> {
818
+ const rows = await this.db
819
+ .select()
820
+ .from(threadMessageTable)
821
+ .where(eq(threadMessageTable.accountConfigId, accountConfigId));
822
+ return rows.map(toItem);
823
+ }
824
+
825
+ async deleteAllByAccount(accountConfigId: string): Promise<number> {
826
+ const result = await this.db
827
+ .delete(threadMessageTable)
828
+ .where(eq(threadMessageTable.accountConfigId, accountConfigId))
829
+ .returning({ id: threadMessageTable.threadMessageId });
830
+ return result.length;
831
+ }
832
+ }