@remit/drizzle-service 0.0.13 → 0.0.14

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.14",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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?: {