@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
|
@@ -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
|
)
|