@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
|
@@ -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?: {
|