@remit/drizzle-service 0.0.13 → 0.0.15

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  } from "./repos/message.js";
41
41
  export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
42
42
  export { MessageLabelRepo } from "./repos/message-label.js";
43
+ export { QuarantineRepo } from "./repos/quarantine.js";
43
44
  export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
44
45
  export { DrizzleUnitOfWork } from "./repos/unit-of-work.js";
45
46
  export * from "./schema/i4-account-config.js";
@@ -54,6 +55,7 @@ export * from "./schema/i4-organize-job-request.js";
54
55
  export * from "./schema/i4-outbox-message.js";
55
56
  export * from "./schema/message-data.js";
56
57
  export { messageDataSchema } from "./schema/message-data.js";
58
+ export * from "./schema/quarantine.js";
57
59
  export {
58
60
  createSqliteDatabase,
59
61
  type SqliteClient,
@@ -11,6 +11,7 @@ import {
11
11
  envelopeTable,
12
12
  mailboxTable,
13
13
  messageTable,
14
+ quarantineTable,
14
15
  } from "../schema.js";
15
16
  import { createSqliteTestDb } from "../test-db-sqlite.js";
16
17
  import {
@@ -22,11 +23,13 @@ import {
22
23
  // this harness pushes must live on a real file (not the in-memory default) for
23
24
  // the deleter's own connection to see it. `deleteMessageSubtree` touches every
24
25
  // message-data child table, so push the full message-data schema plus the
25
- // account and mailbox containers.
26
+ // account and mailbox containers, plus quarantine — the cascade deletes those
27
+ // rows set-wise by account, so the table has to exist here too.
26
28
  const CASCADE_SCHEMA = {
27
29
  ...messageDataSchema,
28
30
  account: accountTable,
29
31
  mailbox: mailboxTable,
32
+ quarantine: quarantineTable,
30
33
  };
31
34
 
32
35
  const log = { info: () => {} };
@@ -16,6 +16,7 @@ import {
16
16
  messageTable,
17
17
  outboxMessageTable,
18
18
  outboxTable,
19
+ quarantineTable,
19
20
  threadMessageTable,
20
21
  } from "../schema.js";
21
22
  import { createTestDb, type TestDb } from "../test-db.js";
@@ -40,6 +41,7 @@ const ROOT_BODY_PART_ID = "00000000-0000-4000-8000-000000000002";
40
41
  const MESSAGE_FLAG_ID = "00000000-0000-4000-8000-000000000003";
41
42
  const BODY_PART_ID = "00000000-0000-4000-8000-000000000004";
42
43
  const OTHER_MAILBOX_ID = "mbx-cascade-2";
44
+ const QUARANTINE_ID = "quarantine-cascade-1";
43
45
 
44
46
  describe("runDrizzleCascadeDelete", () => {
45
47
  let db: TestDb;
@@ -228,6 +230,25 @@ describe("runDrizzleCascadeDelete", () => {
228
230
  updatedAt: NOW,
229
231
  },
230
232
  ]);
233
+
234
+ await db.insert(quarantineTable).values({
235
+ quarantineId: QUARANTINE_ID,
236
+ accountConfigId: ACCOUNT_CONFIG_ID,
237
+ accountId: ACCOUNT_ID,
238
+ mailboxId: MAILBOX_ID,
239
+ uidValidity: 1_712_000_000,
240
+ uid: 40217,
241
+ mailboxPath: "Clients/Acme Holdings",
242
+ quarantinedAt: NOW,
243
+ attempts: 3,
244
+ failureStage: "BodyParse",
245
+ failureCode: "UnterminatedMultipartBoundary",
246
+ failureMessage: "multipart boundary was never closed",
247
+ workerVersion: "worker 1.0.0",
248
+ structure: [{ depth: 0, contentType: "multipart/mixed" }],
249
+ createdAt: NOW,
250
+ updatedAt: NOW,
251
+ });
231
252
  });
232
253
 
233
254
  after(async () => {
@@ -397,6 +418,16 @@ describe("runDrizzleCascadeDelete", () => {
397
418
  0,
398
419
  "both the Seen and Flagged markers (composite key) must be deleted",
399
420
  );
421
+ assert.equal(
422
+ (
423
+ await db
424
+ .select()
425
+ .from(quarantineTable)
426
+ .where(eq(quarantineTable.quarantineId, QUARANTINE_ID))
427
+ ).length,
428
+ 0,
429
+ "quarantine rows carry the user's folder names and parser output, so account deletion must take them",
430
+ );
400
431
  });
401
432
 
402
433
  test("emits a message.removed outbox row for search-index cleanup", async () => {
@@ -9,6 +9,7 @@ import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
9
9
  import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
10
10
  import { outboxMessageTable } from "../schema/i4-outbox-message.js";
11
11
  import { messageDataSchema } from "../schema/message-data.js";
12
+ import { quarantineTable } from "../schema/quarantine.js";
12
13
  import { threadMessageTable } from "../schema/thread-message.js";
13
14
  import { createSqliteDatabase } from "../sqlite-client.js";
14
15
  import { runInTransaction } from "../tx.js";
@@ -151,6 +152,13 @@ export const runDrizzleCascadeDelete = async (
151
152
 
152
153
  const accountIds = (grouped.get("Account") ?? []).map((k) => k.accountId);
153
154
  if (accountIds.length > 0) {
155
+ // Quarantine rows are deleted set-wise by account rather than from an
156
+ // enumerated key, the way message subtrees are. They carry the user's
157
+ // own folder names and parser output quoting the message, so account
158
+ // deletion has to take them even though nothing enumerates them.
159
+ await tx
160
+ .delete(quarantineTable)
161
+ .where(inArray(quarantineTable.accountId, accountIds));
154
162
  await tx
155
163
  .delete(accountTable)
156
164
  .where(inArray(accountTable.accountId, accountIds));
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { quarantineTable } from "../schema/quarantine.js";
4
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
+ import { QuarantineRepo } from "./quarantine.js";
6
+
7
+ describe("QuarantineRepo", () => {
8
+ let db: TestDb;
9
+ let close: () => Promise<void>;
10
+ let repo: QuarantineRepo;
11
+
12
+ const seed = async (
13
+ overrides: Partial<typeof quarantineTable.$inferInsert> & {
14
+ accountConfigId: string;
15
+ },
16
+ ): Promise<string> => {
17
+ const quarantineId = randomId();
18
+ const now = Date.now();
19
+ await db.insert(quarantineTable).values({
20
+ quarantineId,
21
+ accountId: randomId(),
22
+ mailboxId: randomId(),
23
+ uidValidity: 1_712_000_000,
24
+ uid: 40217,
25
+ mailboxPath: "INBOX",
26
+ quarantinedAt: now,
27
+ attempts: 3,
28
+ failureStage: "BodyParse",
29
+ failureCode: "UnterminatedMultipartBoundary",
30
+ failureMessage: "multipart boundary was never closed",
31
+ workerVersion: "worker 1.0.0",
32
+ structure: [{ depth: 0, contentType: "multipart/mixed" }],
33
+ createdAt: now,
34
+ updatedAt: now,
35
+ ...overrides,
36
+ });
37
+ return quarantineId;
38
+ };
39
+
40
+ before(async () => {
41
+ ({ db, close } = await createTestDb());
42
+ repo = new QuarantineRepo(db as never);
43
+ });
44
+
45
+ after(async () => {
46
+ await close();
47
+ });
48
+
49
+ test("lists one user's entries newest first", async () => {
50
+ const accountConfigId = randomId();
51
+ const older = await seed({ accountConfigId, quarantinedAt: 1_000 });
52
+ const newer = await seed({ accountConfigId, quarantinedAt: 2_000 });
53
+
54
+ const entries = await repo.listByAccountConfigId(accountConfigId);
55
+
56
+ assert.deepEqual(
57
+ entries.map((entry) => entry.quarantineId),
58
+ [newer, older],
59
+ );
60
+ });
61
+
62
+ test("never returns another user's entries", async () => {
63
+ const mine = randomId();
64
+ await seed({ accountConfigId: mine });
65
+ await seed({ accountConfigId: randomId() });
66
+
67
+ const entries = await repo.listByAccountConfigId(mine);
68
+
69
+ assert.equal(entries.length, 1);
70
+ assert.equal(entries[0].accountConfigId, mine);
71
+ });
72
+
73
+ test("returns the MIME tree as the pre-order walk it was written as", async () => {
74
+ const accountConfigId = randomId();
75
+ await seed({
76
+ accountConfigId,
77
+ structure: [
78
+ { depth: 0, contentType: "multipart/mixed" },
79
+ { depth: 1, contentType: "text/plain" },
80
+ { depth: 1, contentType: "application/pdf" },
81
+ ],
82
+ });
83
+
84
+ const [entry] = await repo.listByAccountConfigId(accountConfigId);
85
+
86
+ assert.deepEqual(entry.structure, [
87
+ { depth: 0, contentType: "multipart/mixed" },
88
+ { depth: 1, contentType: "text/plain" },
89
+ { depth: 1, contentType: "application/pdf" },
90
+ ]);
91
+ });
92
+
93
+ test("carries the optional diagnostics as absent, not null", async () => {
94
+ const accountConfigId = randomId();
95
+ await seed({ accountConfigId });
96
+
97
+ const [entry] = await repo.listByAccountConfigId(accountConfigId);
98
+
99
+ // A folder nobody appointed a role to, a whole-body failure and an
100
+ // undeclared charset are all ordinary. The API contract makes them
101
+ // optional, so a null row value must not leak through as `null`.
102
+ assert.equal(entry.mailboxRole, undefined);
103
+ assert.equal(entry.failurePartPath, undefined);
104
+ assert.equal(entry.charset, undefined);
105
+ // The message-shape fields come off one optional BODYSTRUCTURE, so a
106
+ // message that failed before it was read carries none of them.
107
+ assert.equal(entry.contentType, undefined);
108
+ assert.equal(entry.transferEncoding, undefined);
109
+ assert.equal(entry.sizeBytes, undefined);
110
+ assert.equal(entry.messageIdHash, undefined);
111
+ });
112
+ });
@@ -0,0 +1,57 @@
1
+ import type {
2
+ IQuarantineRepository,
3
+ QuarantineItem,
4
+ QuarantineMimeNodeItem,
5
+ } from "@remit/data-ports";
6
+ import { desc, eq } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import { quarantineTable } from "../schema/quarantine.js";
9
+
10
+ type DB = NodePgDatabase<Record<string, unknown>>;
11
+
12
+ function rowToItem(row: typeof quarantineTable.$inferSelect): QuarantineItem {
13
+ return {
14
+ quarantineId: row.quarantineId,
15
+ accountConfigId: row.accountConfigId,
16
+ accountId: row.accountId,
17
+ mailboxId: row.mailboxId,
18
+ uidValidity: row.uidValidity,
19
+ uid: row.uid,
20
+ mailboxRole: row.mailboxRole ?? undefined,
21
+ mailboxPath: row.mailboxPath,
22
+ quarantinedAt: row.quarantinedAt,
23
+ attempts: row.attempts,
24
+ failureStage: row.failureStage,
25
+ failureCode: row.failureCode,
26
+ failureMessage: row.failureMessage,
27
+ failurePartPath: row.failurePartPath ?? undefined,
28
+ workerVersion: row.workerVersion,
29
+ contentType: row.contentType ?? undefined,
30
+ transferEncoding: row.transferEncoding ?? undefined,
31
+ charset: row.charset ?? undefined,
32
+ sizeBytes: row.sizeBytes ?? undefined,
33
+ structure: row.structure as QuarantineMimeNodeItem[],
34
+ messageIdHash: row.messageIdHash ?? undefined,
35
+ createdAt: row.createdAt,
36
+ updatedAt: row.updatedAt,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Read side of the message quarantine (issue #72). The rows are written by the
42
+ * sync worker; nothing in the API process creates or clears one.
43
+ */
44
+ export class QuarantineRepo implements IQuarantineRepository {
45
+ constructor(private db: DB) {}
46
+
47
+ listByAccountConfigId = async (
48
+ accountConfigId: string,
49
+ ): Promise<QuarantineItem[]> => {
50
+ const rows = await this.db
51
+ .select()
52
+ .from(quarantineTable)
53
+ .where(eq(quarantineTable.accountConfigId, accountConfigId))
54
+ .orderBy(desc(quarantineTable.quarantinedAt));
55
+ return rows.map(rowToItem);
56
+ };
57
+ }
@@ -382,6 +382,281 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
382
382
  });
383
383
  });
384
384
 
385
+ // ─── searchByDate: the unified listing's cross-folder search mode ─────────────
386
+ // The daily brief's unscoped search reaches every folder of every account in
387
+ // one query, so matching must span the caller-supplied mailbox scope rather
388
+ // than a single mailbox.
389
+
390
+ describe("DrizzleThreadMessageRepository.searchByDate", {
391
+ skip: !RUN_INTEG,
392
+ }, () => {
393
+ let repo: DrizzleThreadMessageRepository;
394
+ const cleanup: Array<() => Promise<void>> = [];
395
+
396
+ before(async () => {
397
+ await setupDb();
398
+ repo = new DrizzleThreadMessageRepository(PG_URL);
399
+ });
400
+
401
+ after(async () => {
402
+ for (const fn of cleanup.reverse()) {
403
+ await fn();
404
+ }
405
+ await repo.close();
406
+ });
407
+
408
+ async function seed(
409
+ accountConfigId: string,
410
+ mailboxId: string,
411
+ rows: Array<Partial<CreateThreadMessageInput>>,
412
+ ): Promise<void> {
413
+ for (const overrides of rows) {
414
+ const created = await repo.create(
415
+ makeInput(accountConfigId, mailboxId, overrides),
416
+ );
417
+ cleanup.push(() => repo.delete(accountConfigId, created.threadMessageId));
418
+ }
419
+ }
420
+
421
+ test("matches across every mailbox in the scope, not just the inbox", async () => {
422
+ const acct = uuid();
423
+ const inbox = uuid();
424
+ const archive = uuid();
425
+ const spam = uuid();
426
+ const now = Date.now();
427
+ await seed(acct, inbox, [
428
+ { subject: "invoice inbox", sentDate: now, internalDate: now },
429
+ ]);
430
+ await seed(acct, archive, [
431
+ { subject: "invoice archive", sentDate: now - 1, internalDate: now - 1 },
432
+ ]);
433
+ await seed(acct, spam, [
434
+ { subject: "invoice spam", sentDate: now - 2, internalDate: now - 2 },
435
+ ]);
436
+
437
+ const result = await repo.searchByDate(
438
+ acct,
439
+ { query: "invoice" },
440
+ { excludeDeleted: true, mailboxIds: new Set([inbox, archive, spam]) },
441
+ );
442
+
443
+ assert.deepEqual(
444
+ result.items.map((r) => r.subject),
445
+ ["invoice inbox", "invoice archive", "invoice spam"],
446
+ "newest first, across all three folders",
447
+ );
448
+ });
449
+
450
+ test("a mailbox outside the scope contributes nothing", async () => {
451
+ const acct = uuid();
452
+ const inbox = uuid();
453
+ const excluded = uuid();
454
+ const now = Date.now();
455
+ await seed(acct, inbox, [
456
+ { subject: "receipt kept", sentDate: now, internalDate: now },
457
+ ]);
458
+ await seed(acct, excluded, [
459
+ { subject: "receipt dropped", sentDate: now - 1, internalDate: now - 1 },
460
+ ]);
461
+
462
+ const result = await repo.searchByDate(
463
+ acct,
464
+ { query: "receipt" },
465
+ { excludeDeleted: true, mailboxIds: new Set([inbox]) },
466
+ );
467
+
468
+ assert.deepEqual(
469
+ result.items.map((r) => r.subject),
470
+ ["receipt kept"],
471
+ );
472
+ });
473
+
474
+ test("every whitespace-separated term must match", async () => {
475
+ const acct = uuid();
476
+ const mbx = uuid();
477
+ const now = Date.now();
478
+ await seed(acct, mbx, [
479
+ {
480
+ subject: "quarterly invoice",
481
+ fromEmail: "billing@acme.test",
482
+ sentDate: now,
483
+ internalDate: now,
484
+ },
485
+ {
486
+ subject: "quarterly report",
487
+ fromEmail: "reports@acme.test",
488
+ sentDate: now - 1,
489
+ internalDate: now - 1,
490
+ },
491
+ ]);
492
+
493
+ const result = await repo.searchByDate(
494
+ acct,
495
+ { query: "quarterly invoice" },
496
+ { excludeDeleted: true, mailboxIds: new Set([mbx]) },
497
+ );
498
+
499
+ assert.deepEqual(
500
+ result.items.map((r) => r.subject),
501
+ ["quarterly invoice"],
502
+ );
503
+ });
504
+
505
+ test("a term matches the From address as well as the subject", async () => {
506
+ const acct = uuid();
507
+ const mbx = uuid();
508
+ const now = Date.now();
509
+ await seed(acct, mbx, [
510
+ {
511
+ subject: "no keyword here",
512
+ fromEmail: "penelope@acme.test",
513
+ sentDate: now,
514
+ internalDate: now,
515
+ },
516
+ ]);
517
+
518
+ const result = await repo.searchByDate(
519
+ acct,
520
+ { query: "penelope" },
521
+ { excludeDeleted: true, mailboxIds: new Set([mbx]) },
522
+ );
523
+
524
+ assert.equal(result.items.length, 1);
525
+ });
526
+
527
+ test("soft-deleted rows stay out", async () => {
528
+ const acct = uuid();
529
+ const mbx = uuid();
530
+ const now = Date.now();
531
+ await seed(acct, mbx, [
532
+ { subject: "parcel live", sentDate: now, internalDate: now },
533
+ {
534
+ subject: "parcel gone",
535
+ isDeleted: true,
536
+ sentDate: now - 1,
537
+ internalDate: now - 1,
538
+ },
539
+ ]);
540
+
541
+ const result = await repo.searchByDate(
542
+ acct,
543
+ { query: "parcel" },
544
+ { excludeDeleted: true, mailboxIds: new Set([mbx]) },
545
+ );
546
+
547
+ assert.deepEqual(
548
+ result.items.map((r) => r.subject),
549
+ ["parcel live"],
550
+ );
551
+ });
552
+
553
+ // A short page means the matches ran out, never that a read window did — the
554
+ // contract the endpoint documents for search mode.
555
+ test("pages over matches, and a full page yields a resumable cursor", async () => {
556
+ const acct = uuid();
557
+ const inbox = uuid();
558
+ const archive = uuid();
559
+ const now = Date.now();
560
+ await seed(acct, inbox, [
561
+ { subject: "noise a", sentDate: now, internalDate: now },
562
+ { subject: "gamma one", sentDate: now - 1, internalDate: now - 1 },
563
+ { subject: "noise b", sentDate: now - 2, internalDate: now - 2 },
564
+ ]);
565
+ await seed(acct, archive, [
566
+ { subject: "gamma two", sentDate: now - 3, internalDate: now - 3 },
567
+ { subject: "gamma three", sentDate: now - 4, internalDate: now - 4 },
568
+ ]);
569
+
570
+ const scope = {
571
+ excludeDeleted: true,
572
+ mailboxIds: new Set([inbox, archive]),
573
+ };
574
+
575
+ const page1 = await repo.searchByDate(
576
+ acct,
577
+ { query: "gamma" },
578
+ { ...scope, limit: 2 },
579
+ );
580
+ assert.deepEqual(
581
+ page1.items.map((r) => r.subject),
582
+ ["gamma one", "gamma two"],
583
+ "a full page of matches, skipping the non-matching newer rows",
584
+ );
585
+ assert.ok(page1.continuationToken, "more matches remain — cursor expected");
586
+
587
+ const page2 = await repo.searchByDate(
588
+ acct,
589
+ { query: "gamma" },
590
+ { ...scope, limit: 2, continuationToken: page1.continuationToken },
591
+ );
592
+ assert.deepEqual(
593
+ page2.items.map((r) => r.subject),
594
+ ["gamma three"],
595
+ "the last page is short because the matches ran out",
596
+ );
597
+ assert.equal(
598
+ page2.continuationToken,
599
+ undefined,
600
+ "a short page ends the pagination",
601
+ );
602
+ });
603
+
604
+ test("starred narrows the search without changing the scope", async () => {
605
+ const acct = uuid();
606
+ const archive = uuid();
607
+ const now = Date.now();
608
+ await seed(acct, archive, [
609
+ {
610
+ subject: "delta starred",
611
+ hasStars: true,
612
+ sentDate: now,
613
+ internalDate: now,
614
+ },
615
+ {
616
+ subject: "delta plain",
617
+ hasStars: false,
618
+ sentDate: now - 1,
619
+ internalDate: now - 1,
620
+ },
621
+ ]);
622
+
623
+ const result = await repo.searchByDate(
624
+ acct,
625
+ { query: "delta", starred: true },
626
+ { excludeDeleted: true, mailboxIds: new Set([archive]) },
627
+ );
628
+
629
+ assert.deepEqual(
630
+ result.items.map((r) => r.subject),
631
+ ["delta starred"],
632
+ );
633
+ });
634
+
635
+ test("another config's mail is never returned", async () => {
636
+ const mine = uuid();
637
+ const theirs = uuid();
638
+ const mbx = uuid();
639
+ const now = Date.now();
640
+ await seed(mine, mbx, [
641
+ { subject: "epsilon mine", sentDate: now, internalDate: now },
642
+ ]);
643
+ await seed(theirs, mbx, [
644
+ { subject: "epsilon theirs", sentDate: now - 1, internalDate: now - 1 },
645
+ ]);
646
+
647
+ const result = await repo.searchByDate(
648
+ mine,
649
+ { query: "epsilon" },
650
+ { excludeDeleted: true, mailboxIds: new Set([mbx]) },
651
+ );
652
+
653
+ assert.deepEqual(
654
+ result.items.map((r) => r.subject),
655
+ ["epsilon mine"],
656
+ );
657
+ });
658
+ });
659
+
385
660
  // ─── Native text-search semantics ─────────────────────────────────────────────
386
661
  // The type-ahead search box lowercases the query before sending it. These tests
387
662
  // pin the Postgres-native behaviour: case- and accent-insensitive substring
@@ -478,6 +478,65 @@ export class DrizzleThreadMessageRepository
478
478
  };
479
479
  }
480
480
 
481
+ /**
482
+ * Cross-mailbox search for the unified listing's search mode. Same predicate
483
+ * builder and keyset cursor as `searchByMailboxWindow`, with the mailbox
484
+ * equality swapped for the caller's scope set. Matching runs in SQL over the
485
+ * whole scope, so a short page means the matches are exhausted.
486
+ */
487
+ async searchByDate(
488
+ accountConfigId: string,
489
+ search: SearchOptions,
490
+ options?: {
491
+ order?: "asc" | "desc";
492
+ limit?: number;
493
+ continuationToken?: string;
494
+ mailboxIds?: Set<string>;
495
+ excludeDeleted?: boolean;
496
+ },
497
+ ): Promise<ResultList<ThreadMessageItem>> {
498
+ const order = options?.order ?? "desc";
499
+ const limit = clampThreadSearchLimit(options?.limit);
500
+ const cursor = options?.continuationToken
501
+ ? decodeDateCursor(options.continuationToken)
502
+ : null;
503
+
504
+ const mailboxCond = options?.mailboxIds?.size
505
+ ? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
506
+ : undefined;
507
+
508
+ const rows = await this.db
509
+ .select()
510
+ .from(threadMessageTable)
511
+ .where(
512
+ and(
513
+ eq(threadMessageTable.accountConfigId, accountConfigId),
514
+ mailboxCond,
515
+ options?.excludeDeleted
516
+ ? eq(threadMessageTable.isDeleted, false)
517
+ : undefined,
518
+ ...buildSearchConditions(search),
519
+ sentDateCursorCond(order, cursor),
520
+ ),
521
+ )
522
+ .orderBy(
523
+ order === "desc"
524
+ ? desc(threadMessageTable.sentDate)
525
+ : asc(threadMessageTable.sentDate),
526
+ asc(threadMessageTable.threadMessageId),
527
+ )
528
+ .limit(limit);
529
+
530
+ const lastRow = rows[rows.length - 1];
531
+ return {
532
+ items: rows.map(toItem),
533
+ continuationToken:
534
+ rows.length === limit && lastRow
535
+ ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
536
+ : undefined,
537
+ };
538
+ }
539
+
481
540
  async listByStarred(
482
541
  accountConfigId: string,
483
542
  options?: {
@@ -0,0 +1,3 @@
1
+ import { entities } from "./active-entities.js";
2
+
3
+ export const quarantineTable = entities.quarantines;
package/src/schema.ts CHANGED
@@ -25,4 +25,5 @@ export * from "./schema/i4-message-placement-move.js";
25
25
  export * from "./schema/i4-organize-job-request.js";
26
26
  export * from "./schema/i4-outbox-message.js";
27
27
  export * from "./schema/message-data.js";
28
+ export * from "./schema/quarantine.js";
28
29
  export { threadMessageTable } from "./schema/thread-message.js";