@remit/drizzle-service 0.0.73 → 0.0.75

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.73",
3
+ "version": "0.0.75",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -472,6 +472,316 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
472
472
  );
473
473
  });
474
474
 
475
+ // #308: the Flagged view filtered by category over the pages it had loaded,
476
+ // so a category whose mail sits below the newest page rendered an empty list
477
+ // however much of it the collection held. Seeded so the newest page holds
478
+ // none of the target category: the filter has to run inside the query.
479
+ test("listByStarred filters by category below the newest page", async () => {
480
+ const acct = "acct-starred-category";
481
+ const base = Date.now();
482
+ const pageSize = 10;
483
+ for (let i = 0; i < pageSize; i++) {
484
+ await repo.create(
485
+ makeInput({
486
+ accountConfigId: acct,
487
+ subject: `newest ${i}`,
488
+ hasStars: true,
489
+ category: "personal",
490
+ sentDate: base - i,
491
+ internalDate: base - i,
492
+ }),
493
+ );
494
+ }
495
+ for (let i = 0; i < 3; i++) {
496
+ await repo.create(
497
+ makeInput({
498
+ accountConfigId: acct,
499
+ subject: `older ${i}`,
500
+ hasStars: true,
501
+ category: "social",
502
+ sentDate: base - pageSize - i,
503
+ internalDate: base - pageSize - i,
504
+ }),
505
+ );
506
+ }
507
+
508
+ const unfiltered = await repo.listByStarred(acct, { limit: pageSize });
509
+ assert.ok(
510
+ unfiltered.items.every((i) => i.category === "personal"),
511
+ "the newest page holds none of the target category",
512
+ );
513
+
514
+ const filtered = await repo.listByStarred(acct, {
515
+ limit: pageSize,
516
+ search: { category: ["social"] },
517
+ });
518
+ assert.deepEqual(
519
+ filtered.items.map((i) => i.subject),
520
+ ["older 0", "older 1", "older 2"],
521
+ );
522
+ });
523
+
524
+ test("listByStarred filters by unread and attachment inside the query", async () => {
525
+ const acct = "acct-starred-attrs";
526
+ const base = Date.now();
527
+ await repo.create(
528
+ makeInput({
529
+ accountConfigId: acct,
530
+ subject: "unread with attachment",
531
+ hasStars: true,
532
+ isRead: false,
533
+ hasAttachment: true,
534
+ sentDate: base,
535
+ internalDate: base,
536
+ }),
537
+ );
538
+ await repo.create(
539
+ makeInput({
540
+ accountConfigId: acct,
541
+ subject: "read with attachment",
542
+ hasStars: true,
543
+ isRead: true,
544
+ hasAttachment: true,
545
+ sentDate: base - 1,
546
+ internalDate: base - 1,
547
+ }),
548
+ );
549
+ await repo.create(
550
+ makeInput({
551
+ accountConfigId: acct,
552
+ subject: "unread without attachment",
553
+ hasStars: true,
554
+ isRead: false,
555
+ hasAttachment: false,
556
+ sentDate: base - 2,
557
+ internalDate: base - 2,
558
+ }),
559
+ );
560
+
561
+ const unread = await repo.listByStarred(acct, {
562
+ search: { unread: true },
563
+ });
564
+ assert.deepEqual(
565
+ unread.items.map((i) => i.subject),
566
+ ["unread with attachment", "unread without attachment"],
567
+ );
568
+
569
+ const both = await repo.listByStarred(acct, {
570
+ search: { unread: true, attachments: true },
571
+ });
572
+ assert.deepEqual(
573
+ both.items.map((i) => i.subject),
574
+ ["unread with attachment"],
575
+ );
576
+ });
577
+
578
+ test("listByDate filters by category inside the query", async () => {
579
+ const acct = "acct-date-category";
580
+ const base = Date.now();
581
+ await repo.create(
582
+ makeInput({
583
+ accountConfigId: acct,
584
+ subject: "social row",
585
+ category: "social",
586
+ sentDate: base,
587
+ internalDate: base,
588
+ }),
589
+ );
590
+ await repo.create(
591
+ makeInput({
592
+ accountConfigId: acct,
593
+ subject: "personal row",
594
+ category: "personal",
595
+ sentDate: base - 1,
596
+ internalDate: base - 1,
597
+ }),
598
+ );
599
+
600
+ const result = await repo.listByDate(acct, {
601
+ search: { category: ["social"] },
602
+ });
603
+ assert.deepEqual(
604
+ result.items.map((i) => i.subject),
605
+ ["social row"],
606
+ );
607
+ });
608
+
609
+ test("countThreadsInScope counts every match, whatever the page size", async () => {
610
+ const acct = "acct-count-scope";
611
+ const base = Date.now();
612
+ for (let i = 0; i < 7; i++) {
613
+ await repo.create(
614
+ makeInput({
615
+ accountConfigId: acct,
616
+ subject: `unread ${i}`,
617
+ hasStars: true,
618
+ isRead: false,
619
+ sentDate: base - i,
620
+ internalDate: base - i,
621
+ }),
622
+ );
623
+ }
624
+ await repo.create(
625
+ makeInput({
626
+ accountConfigId: acct,
627
+ subject: "read",
628
+ hasStars: true,
629
+ isRead: true,
630
+ sentDate: base - 100,
631
+ internalDate: base - 100,
632
+ }),
633
+ );
634
+
635
+ const page = await repo.listByStarred(acct, {
636
+ limit: 2,
637
+ search: { starred: true, unread: true },
638
+ });
639
+ assert.equal(page.items.length, 2);
640
+
641
+ const count = await repo.countThreadsInScope(acct, {
642
+ starred: true,
643
+ unread: true,
644
+ });
645
+ assert.equal(count, 7);
646
+ });
647
+
648
+ test("countThreadsInScope takes a category union and counts uncategorized", async () => {
649
+ const acct = "acct-count-category";
650
+ const base = Date.now();
651
+ const seed = async (
652
+ category: NonNullable<CreateThreadMessageInput["category"]>,
653
+ n: number,
654
+ ) => {
655
+ for (let i = 0; i < n; i++) {
656
+ await repo.create(
657
+ makeInput({
658
+ accountConfigId: acct,
659
+ subject: `${category} ${i}`,
660
+ hasStars: true,
661
+ category,
662
+ sentDate: base - i,
663
+ internalDate: base - i,
664
+ }),
665
+ );
666
+ }
667
+ };
668
+ await seed("social", 2);
669
+ await seed("personal", 3);
670
+ await seed("uncategorized", 4);
671
+
672
+ assert.equal(
673
+ await repo.countThreadsInScope(acct, {
674
+ starred: true,
675
+ category: ["social", "personal"],
676
+ }),
677
+ 5,
678
+ );
679
+ assert.equal(
680
+ await repo.countThreadsInScope(acct, {
681
+ starred: true,
682
+ category: ["uncategorized"],
683
+ }),
684
+ 4,
685
+ );
686
+ });
687
+
688
+ test("countThreadsInScope narrows to the supplied mailbox set", async () => {
689
+ const acct = "acct-count-mailboxes";
690
+ await repo.create(
691
+ makeInput({
692
+ accountConfigId: acct,
693
+ mailboxId: "mbx-in-scope",
694
+ subject: "in scope",
695
+ hasStars: true,
696
+ }),
697
+ );
698
+ await repo.create(
699
+ makeInput({
700
+ accountConfigId: acct,
701
+ mailboxId: "mbx-out-of-scope",
702
+ subject: "out of scope",
703
+ hasStars: true,
704
+ }),
705
+ );
706
+
707
+ assert.equal(
708
+ await repo.countThreadsInScope(
709
+ acct,
710
+ { starred: true },
711
+ { mailboxIds: new Set(["mbx-in-scope"]) },
712
+ ),
713
+ 1,
714
+ );
715
+ });
716
+
717
+ // The count sits above a list that has been collapsed by thread, so it has to
718
+ // collapse with it. Counting rows would put a third number on screen: the
719
+ // header would say two where the list shows one conversation.
720
+ test("countThreadsInScope counts a conversation once, not its messages", async () => {
721
+ const acct = "acct-count-thread";
722
+ const base = Date.now();
723
+ const threadId = "t-shared-conversation";
724
+ for (let i = 0; i < 2; i++) {
725
+ await repo.create(
726
+ makeInput({
727
+ accountConfigId: acct,
728
+ threadId,
729
+ subject: `reply ${i}`,
730
+ hasStars: true,
731
+ isRead: false,
732
+ sentDate: base - i,
733
+ internalDate: base - i,
734
+ }),
735
+ );
736
+ }
737
+ await repo.create(
738
+ makeInput({
739
+ accountConfigId: acct,
740
+ subject: "another conversation",
741
+ hasStars: true,
742
+ isRead: false,
743
+ sentDate: base - 10,
744
+ internalDate: base - 10,
745
+ }),
746
+ );
747
+
748
+ const rows = await repo.listByStarred(acct, {
749
+ search: { starred: true, unread: true },
750
+ });
751
+ assert.equal(rows.items.length, 3, "three rows, two conversations");
752
+ assert.equal(
753
+ await repo.countThreadsInScope(acct, { starred: true, unread: true }),
754
+ 2,
755
+ );
756
+ });
757
+
758
+ // One message the server offers through a real folder and through two
759
+ // virtual copies of it. The listing collapses the copies; so does the count.
760
+ test("countThreadsInScope counts one message once across its copies", async () => {
761
+ const acct = "acct-count-copies";
762
+ const threadId = "t-one-message";
763
+ const base = Date.now();
764
+ for (const mailboxId of ["mbx-inbox", "mbx-all", "mbx-starred"]) {
765
+ await repo.create(
766
+ makeInput({
767
+ accountConfigId: acct,
768
+ threadId,
769
+ mailboxId,
770
+ subject: "one message, three placements",
771
+ hasStars: true,
772
+ isRead: false,
773
+ sentDate: base,
774
+ internalDate: base,
775
+ }),
776
+ );
777
+ }
778
+
779
+ assert.equal(
780
+ await repo.countThreadsInScope(acct, { starred: true, unread: true }),
781
+ 1,
782
+ );
783
+ });
784
+
475
785
  test("listByStarred excludes soft-deleted rows when asked", async () => {
476
786
  const acct = "acct-starred-deleted";
477
787
  await repo.create(
@@ -321,6 +321,57 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
321
321
  );
322
322
  assert.equal(count, 5, "asc counts the whole match set too");
323
323
  });
324
+
325
+ // ── Scenario 7 ────────────────────────────────────────────────────────────
326
+ // The count and the result set are two answers to one predicate, and the
327
+ // surfaces that render the number stopped paging to derive it (#307). This
328
+ // is the guard from the other direction: walk every page and assert the walk
329
+ // and the count agree, so the number cannot silently drift from the rows.
330
+ test("the count agrees with a full page-through of the same predicate", async () => {
331
+ const acct = uuid();
332
+ const mbx = uuid();
333
+ const now = Date.now();
334
+ await seed(acct, mbx, [
335
+ ...Array.from({ length: 17 }, (_, i) => ({
336
+ subject: `gamma ${i}`,
337
+ sentDate: now - i,
338
+ internalDate: now - i,
339
+ })),
340
+ { subject: "delta", sentDate: now - 100, internalDate: now - 100 },
341
+ ]);
342
+
343
+ const walked = new Set<string>();
344
+ let continuationToken: string | undefined;
345
+ let pages = 0;
346
+ do {
347
+ const page = await repo.searchByMailboxWindow(
348
+ acct,
349
+ mbx,
350
+ { subject: "gamma" },
351
+ { excludeDeleted: true, limit: 5, continuationToken },
352
+ );
353
+ for (const row of page.items) walked.add(row.threadMessageId);
354
+ continuationToken = page.continuationToken;
355
+ pages += 1;
356
+ } while (continuationToken && pages < 10);
357
+ assert.ok(
358
+ !continuationToken,
359
+ "the walk never reached the end of the pages",
360
+ );
361
+
362
+ const count = await repo.countByMailbox(
363
+ acct,
364
+ mbx,
365
+ { subject: "gamma" },
366
+ { excludeDeleted: true },
367
+ );
368
+ assert.equal(
369
+ count,
370
+ walked.size,
371
+ "the count and the rows the predicate returns disagree",
372
+ );
373
+ assert.equal(count, 17, "the unmatched row leaked into one of the two");
374
+ });
324
375
  });
325
376
 
326
377
  // ─── searchByDate: the unified listing's cross-folder search mode ─────────────
@@ -407,6 +407,7 @@ export class DrizzleThreadMessageRepository
407
407
  continuationToken?: string;
408
408
  inboxMailboxIds?: Set<string>;
409
409
  excludeDeleted?: boolean;
410
+ search?: SearchOptions;
410
411
  },
411
412
  ): Promise<ResultList<ThreadMessageItem>> {
412
413
  const order = options?.order ?? "desc";
@@ -446,6 +447,7 @@ export class DrizzleThreadMessageRepository
446
447
  options?.excludeDeleted
447
448
  ? eq(threadMessageTable.isDeleted, false)
448
449
  : undefined,
450
+ ...(options?.search ? buildSearchConditions(options.search) : []),
449
451
  cursorCond,
450
452
  ),
451
453
  )
@@ -528,6 +530,50 @@ export class DrizzleThreadMessageRepository
528
530
  };
529
531
  }
530
532
 
533
+ /**
534
+ * COUNT of matching CONVERSATIONS over the SAME predicate as the
535
+ * cross-account listings, across the caller's mailbox scope.
536
+ *
537
+ * Distinct on `threadId` because that is the unit the listing renders: a row
538
+ * is per mailbox, so one message reachable through a real folder and a
539
+ * virtual copy of it is several rows, and two matching messages of one
540
+ * conversation are two more. Both collapse in the list, and a count that did
541
+ * not collapse with them would name a different set than the rows it sits
542
+ * above.
543
+ *
544
+ * `hasStars` is not special-cased: the starred mode passes `starred: true` in
545
+ * `search`, so one method counts what any of the three modes lists.
546
+ */
547
+ async countThreadsInScope(
548
+ accountConfigId: string,
549
+ search: SearchOptions,
550
+ options?: {
551
+ mailboxIds?: Set<string>;
552
+ excludeDeleted?: boolean;
553
+ },
554
+ ): Promise<number> {
555
+ const mailboxCond = options?.mailboxIds?.size
556
+ ? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
557
+ : undefined;
558
+
559
+ const [{ count }] = await this.db
560
+ .select({
561
+ count: sql<number>`cast(count(distinct ${threadMessageTable.threadId}) as int)`,
562
+ })
563
+ .from(threadMessageTable)
564
+ .where(
565
+ and(
566
+ eq(threadMessageTable.accountConfigId, accountConfigId),
567
+ mailboxCond,
568
+ options?.excludeDeleted
569
+ ? eq(threadMessageTable.isDeleted, false)
570
+ : undefined,
571
+ ...buildSearchConditions(search),
572
+ ),
573
+ );
574
+ return count;
575
+ }
576
+
531
577
  async listByStarred(
532
578
  accountConfigId: string,
533
579
  options?: {
@@ -536,6 +582,7 @@ export class DrizzleThreadMessageRepository
536
582
  continuationToken?: string;
537
583
  mailboxIds?: Set<string>;
538
584
  excludeDeleted?: boolean;
585
+ search?: SearchOptions;
539
586
  },
540
587
  ): Promise<ResultList<ThreadMessageItem>> {
541
588
  const order = options?.order ?? "desc";
@@ -561,6 +608,7 @@ export class DrizzleThreadMessageRepository
561
608
  options?.excludeDeleted
562
609
  ? eq(threadMessageTable.isDeleted, false)
563
610
  : undefined,
611
+ ...(options?.search ? buildSearchConditions(options.search) : []),
564
612
  sentDateCursorCond(order, cursor),
565
613
  ),
566
614
  )