@remit/drizzle-service 0.0.86 → 0.0.87
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
|
import assert from "node:assert/strict";
|
|
2
2
|
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
3
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
4
4
|
import {
|
|
5
5
|
envelopeId as deriveEnvelopeId,
|
|
6
6
|
rootBodyPartId as deriveRootBodyPartId,
|
|
@@ -249,6 +249,63 @@ describe("DrizzleMessageRepository (sqlite)", () => {
|
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
test("requestReembed queues a forced re-index for each message that still exists", async () => {
|
|
253
|
+
const movedRows = () =>
|
|
254
|
+
db
|
|
255
|
+
.select()
|
|
256
|
+
.from(outboxTable)
|
|
257
|
+
.where(
|
|
258
|
+
and(
|
|
259
|
+
eq(outboxTable.messageId, MESSAGE_ID),
|
|
260
|
+
eq(outboxTable.event, "message.moved"),
|
|
261
|
+
isNull(outboxTable.processedAt),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
const pendingBefore = (await movedRows()).length;
|
|
265
|
+
|
|
266
|
+
const queued = await repo.requestReembed([
|
|
267
|
+
MESSAGE_ID,
|
|
268
|
+
"00000000-0000-0000-2222-0000000000fe",
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(queued, { queued: 1, alreadyQueued: 0 });
|
|
272
|
+
assert.equal((await movedRows()).length, pendingBefore + 1);
|
|
273
|
+
const orphan = await db
|
|
274
|
+
.select()
|
|
275
|
+
.from(outboxTable)
|
|
276
|
+
.where(eq(outboxTable.messageId, "00000000-0000-0000-2222-0000000000fe"));
|
|
277
|
+
assert.equal(orphan.length, 0);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("requestReembed skips a message whose forced re-index is still pending", async () => {
|
|
281
|
+
const pendingBefore = await db
|
|
282
|
+
.select()
|
|
283
|
+
.from(outboxTable)
|
|
284
|
+
.where(
|
|
285
|
+
and(
|
|
286
|
+
eq(outboxTable.messageId, MESSAGE_ID),
|
|
287
|
+
eq(outboxTable.event, "message.moved"),
|
|
288
|
+
isNull(outboxTable.processedAt),
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
assert.ok(pendingBefore.length > 0);
|
|
292
|
+
|
|
293
|
+
const again = await repo.requestReembed([MESSAGE_ID]);
|
|
294
|
+
|
|
295
|
+
assert.deepEqual(again, { queued: 0, alreadyQueued: 1 });
|
|
296
|
+
const pendingAfter = await db
|
|
297
|
+
.select()
|
|
298
|
+
.from(outboxTable)
|
|
299
|
+
.where(
|
|
300
|
+
and(
|
|
301
|
+
eq(outboxTable.messageId, MESSAGE_ID),
|
|
302
|
+
eq(outboxTable.event, "message.moved"),
|
|
303
|
+
isNull(outboxTable.processedAt),
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
assert.equal(pendingAfter.length, pendingBefore.length);
|
|
307
|
+
});
|
|
308
|
+
|
|
252
309
|
test("delete removes the message and appends a removal outbox row", async () => {
|
|
253
310
|
await repo.delete(MESSAGE_ID);
|
|
254
311
|
await assert.rejects(() => repo.get(MESSAGE_ID));
|
package/src/repos/message.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
MessageItem,
|
|
7
7
|
PlacementPredicate,
|
|
8
8
|
} from "@remit/data-ports";
|
|
9
|
-
import { and, asc, eq, gt, inArray, or, type SQL } from "drizzle-orm";
|
|
9
|
+
import { and, asc, eq, gt, inArray, isNull, or, type SQL } from "drizzle-orm";
|
|
10
10
|
|
|
11
11
|
import type { Db } from "../db.js";
|
|
12
12
|
import {
|
|
@@ -47,6 +47,13 @@ import {
|
|
|
47
47
|
|
|
48
48
|
type DB = Db<MessageDataSchema>;
|
|
49
49
|
|
|
50
|
+
const REEMBED_BATCH_SIZE = 500;
|
|
51
|
+
|
|
52
|
+
export interface ReembedRequest {
|
|
53
|
+
queued: number;
|
|
54
|
+
alreadyQueued: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
51
58
|
return {
|
|
52
59
|
messageId: row.messageId,
|
|
@@ -632,4 +639,48 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
632
639
|
});
|
|
633
640
|
return toMessageItem(rows[0]);
|
|
634
641
|
}
|
|
642
|
+
|
|
643
|
+
async requestReembed(messageIds: string[]): Promise<ReembedRequest> {
|
|
644
|
+
const request: ReembedRequest = { queued: 0, alreadyQueued: 0 };
|
|
645
|
+
for (
|
|
646
|
+
let start = 0;
|
|
647
|
+
start < messageIds.length;
|
|
648
|
+
start += REEMBED_BATCH_SIZE
|
|
649
|
+
) {
|
|
650
|
+
const batch = messageIds.slice(start, start + REEMBED_BATCH_SIZE);
|
|
651
|
+
await runInTransaction(this.db, async (tx) => {
|
|
652
|
+
const present = await tx
|
|
653
|
+
.select({ messageId: messageTable.messageId })
|
|
654
|
+
.from(messageTable)
|
|
655
|
+
.where(inArray(messageTable.messageId, batch));
|
|
656
|
+
const pending = await tx
|
|
657
|
+
.selectDistinct({ messageId: outboxTable.messageId })
|
|
658
|
+
.from(outboxTable)
|
|
659
|
+
.where(
|
|
660
|
+
and(
|
|
661
|
+
inArray(outboxTable.messageId, batch),
|
|
662
|
+
eq(outboxTable.event, "message.moved"),
|
|
663
|
+
isNull(outboxTable.processedAt),
|
|
664
|
+
),
|
|
665
|
+
);
|
|
666
|
+
const pendingIds = new Set(pending.map(({ messageId }) => messageId));
|
|
667
|
+
const toQueue = present.filter(
|
|
668
|
+
({ messageId }) => !pendingIds.has(messageId),
|
|
669
|
+
);
|
|
670
|
+
request.alreadyQueued += present.length - toQueue.length;
|
|
671
|
+
request.queued += toQueue.length;
|
|
672
|
+
if (toQueue.length === 0) return;
|
|
673
|
+
await tx.insert(outboxTable).values(
|
|
674
|
+
toQueue.map(({ messageId }) => ({
|
|
675
|
+
id: randomUUID(),
|
|
676
|
+
messageId,
|
|
677
|
+
event: "message.moved" as const,
|
|
678
|
+
payload: { messageId },
|
|
679
|
+
createdAt: new Date(),
|
|
680
|
+
})),
|
|
681
|
+
);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return request;
|
|
685
|
+
}
|
|
635
686
|
}
|
|
@@ -647,6 +647,258 @@ describe("DrizzleThreadMessageRepository.searchByDate", () => {
|
|
|
647
647
|
["epsilon mine"],
|
|
648
648
|
);
|
|
649
649
|
});
|
|
650
|
+
test("a sender match ranks above a newer mail that only mentions the term", async () => {
|
|
651
|
+
const acct = uuid();
|
|
652
|
+
const mbx = uuid();
|
|
653
|
+
const now = Date.now();
|
|
654
|
+
await seed(acct, mbx, [
|
|
655
|
+
{
|
|
656
|
+
subject: "lunch plans",
|
|
657
|
+
snippet: "I spoke to penny about it",
|
|
658
|
+
fromEmail: "bob@acme.test",
|
|
659
|
+
sentDate: now,
|
|
660
|
+
internalDate: now,
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
subject: "penny wise",
|
|
664
|
+
fromEmail: "carol@acme.test",
|
|
665
|
+
sentDate: now - 1,
|
|
666
|
+
internalDate: now - 1,
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
subject: "hello",
|
|
670
|
+
fromName: "Penny Lane",
|
|
671
|
+
fromEmail: "lane@acme.test",
|
|
672
|
+
sentDate: now - 2,
|
|
673
|
+
internalDate: now - 2,
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
subject: "older hello",
|
|
677
|
+
fromEmail: "penny@acme.test",
|
|
678
|
+
sentDate: now - 3,
|
|
679
|
+
internalDate: now - 3,
|
|
680
|
+
},
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
const result = await repo.searchByDate(
|
|
684
|
+
acct,
|
|
685
|
+
{ query: "penny" },
|
|
686
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
assert.deepEqual(
|
|
690
|
+
result.items.map((r) => r.subject),
|
|
691
|
+
["hello", "older hello", "lunch plans", "penny wise"],
|
|
692
|
+
"sender matches first, newest first inside each tier",
|
|
693
|
+
);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("one term hitting the sender lifts the mail into the sender tier", async () => {
|
|
697
|
+
const acct = uuid();
|
|
698
|
+
const mbx = uuid();
|
|
699
|
+
const now = Date.now();
|
|
700
|
+
await seed(acct, mbx, [
|
|
701
|
+
{
|
|
702
|
+
subject: "invoice for zeta",
|
|
703
|
+
fromEmail: "billing@acme.test",
|
|
704
|
+
sentDate: now,
|
|
705
|
+
internalDate: now,
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
subject: "your invoice",
|
|
709
|
+
fromEmail: "zeta@acme.test",
|
|
710
|
+
sentDate: now - 1,
|
|
711
|
+
internalDate: now - 1,
|
|
712
|
+
},
|
|
713
|
+
]);
|
|
714
|
+
|
|
715
|
+
const result = await repo.searchByDate(
|
|
716
|
+
acct,
|
|
717
|
+
{ query: "zeta invoice" },
|
|
718
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
assert.deepEqual(
|
|
722
|
+
result.items.map((r) => r.subject),
|
|
723
|
+
["your invoice", "invoice for zeta"],
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("paging walks the sender tier, then the mentions, without a gap or repeat", async () => {
|
|
728
|
+
const acct = uuid();
|
|
729
|
+
const mbx = uuid();
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
await seed(acct, mbx, [
|
|
732
|
+
{ subject: "kappa news", sentDate: now, internalDate: now },
|
|
733
|
+
{
|
|
734
|
+
subject: "one",
|
|
735
|
+
fromEmail: "kappa@acme.test",
|
|
736
|
+
sentDate: now - 1,
|
|
737
|
+
internalDate: now - 1,
|
|
738
|
+
},
|
|
739
|
+
{ subject: "kappa digest", sentDate: now - 2, internalDate: now - 2 },
|
|
740
|
+
{
|
|
741
|
+
subject: "two",
|
|
742
|
+
fromEmail: "kappa@acme.test",
|
|
743
|
+
sentDate: now - 3,
|
|
744
|
+
internalDate: now - 3,
|
|
745
|
+
},
|
|
746
|
+
{ subject: "kappa recap", sentDate: now - 4, internalDate: now - 4 },
|
|
747
|
+
]);
|
|
748
|
+
const scope = { excludeDeleted: true, mailboxIds: new Set([mbx]) };
|
|
749
|
+
|
|
750
|
+
const walked: string[] = [];
|
|
751
|
+
let continuationToken: string | undefined;
|
|
752
|
+
do {
|
|
753
|
+
const page = await repo.searchByDate(
|
|
754
|
+
acct,
|
|
755
|
+
{ query: "kappa" },
|
|
756
|
+
{ ...scope, limit: 2, continuationToken },
|
|
757
|
+
);
|
|
758
|
+
walked.push(...page.items.map((r) => r.subject ?? ""));
|
|
759
|
+
continuationToken = page.continuationToken;
|
|
760
|
+
} while (continuationToken);
|
|
761
|
+
|
|
762
|
+
assert.deepEqual(walked, [
|
|
763
|
+
"one",
|
|
764
|
+
"two",
|
|
765
|
+
"kappa news",
|
|
766
|
+
"kappa digest",
|
|
767
|
+
"kappa recap",
|
|
768
|
+
]);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
test("date ties at the tier boundary page without a gap or repeat, in either order", async () => {
|
|
772
|
+
const acct = uuid();
|
|
773
|
+
const mbx = uuid();
|
|
774
|
+
const tie = Date.now();
|
|
775
|
+
await seed(acct, mbx, [
|
|
776
|
+
{
|
|
777
|
+
subject: "sender a",
|
|
778
|
+
fromEmail: "lambda@acme.test",
|
|
779
|
+
sentDate: tie,
|
|
780
|
+
internalDate: tie,
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
subject: "sender b",
|
|
784
|
+
fromEmail: "lambda@acme.test",
|
|
785
|
+
sentDate: tie,
|
|
786
|
+
internalDate: tie,
|
|
787
|
+
},
|
|
788
|
+
{ subject: "lambda mention a", sentDate: tie, internalDate: tie },
|
|
789
|
+
{ subject: "lambda mention b", sentDate: tie, internalDate: tie },
|
|
790
|
+
]);
|
|
791
|
+
const scope = { excludeDeleted: true, mailboxIds: new Set([mbx]) };
|
|
792
|
+
|
|
793
|
+
for (const order of ["desc", "asc"] as const) {
|
|
794
|
+
const walked: string[] = [];
|
|
795
|
+
let continuationToken: string | undefined;
|
|
796
|
+
do {
|
|
797
|
+
const page = await repo.searchByDate(
|
|
798
|
+
acct,
|
|
799
|
+
{ query: "lambda" },
|
|
800
|
+
{ ...scope, order, limit: 1, continuationToken },
|
|
801
|
+
);
|
|
802
|
+
walked.push(...page.items.map((r) => r.subject ?? ""));
|
|
803
|
+
continuationToken = page.continuationToken;
|
|
804
|
+
} while (continuationToken);
|
|
805
|
+
|
|
806
|
+
assert.equal(walked.length, 4, `${order}: a row was skipped or repeated`);
|
|
807
|
+
assert.deepEqual(
|
|
808
|
+
walked.slice(0, 2).sort(),
|
|
809
|
+
["sender a", "sender b"],
|
|
810
|
+
`${order}: the sender tier comes first`,
|
|
811
|
+
);
|
|
812
|
+
assert.deepEqual(
|
|
813
|
+
walked.slice(2).sort(),
|
|
814
|
+
["lambda mention a", "lambda mention b"],
|
|
815
|
+
`${order}: the mentions follow`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("a ranked page hands back a versioned cursor", async () => {
|
|
821
|
+
const acct = uuid();
|
|
822
|
+
const mbx = uuid();
|
|
823
|
+
const now = Date.now();
|
|
824
|
+
await seed(acct, mbx, [
|
|
825
|
+
{ subject: "mu one", sentDate: now, internalDate: now },
|
|
826
|
+
{ subject: "mu two", sentDate: now - 1, internalDate: now - 1 },
|
|
827
|
+
]);
|
|
828
|
+
|
|
829
|
+
const page = await repo.searchByDate(
|
|
830
|
+
acct,
|
|
831
|
+
{ query: "mu one" },
|
|
832
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]), limit: 1 },
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
assert.ok(page.continuationToken);
|
|
836
|
+
const decoded = JSON.parse(
|
|
837
|
+
Buffer.from(page.continuationToken, "base64").toString("utf8"),
|
|
838
|
+
);
|
|
839
|
+
assert.equal(decoded.v, 2);
|
|
840
|
+
assert.equal(typeof decoded.r, "number");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
test("a cursor minted before the ranking finishes its session in date order", async () => {
|
|
844
|
+
const acct = uuid();
|
|
845
|
+
const mbx = uuid();
|
|
846
|
+
const now = Date.now();
|
|
847
|
+
const rows: Array<Partial<CreateThreadMessageInput>> = [
|
|
848
|
+
{ subject: "nu news", sentDate: now, internalDate: now },
|
|
849
|
+
{ subject: "nu digest", sentDate: now - 1, internalDate: now - 1 },
|
|
850
|
+
{
|
|
851
|
+
subject: "from nu",
|
|
852
|
+
fromEmail: "nu@acme.test",
|
|
853
|
+
sentDate: now - 2,
|
|
854
|
+
internalDate: now - 2,
|
|
855
|
+
},
|
|
856
|
+
{ subject: "nu recap", sentDate: now - 3, internalDate: now - 3 },
|
|
857
|
+
{
|
|
858
|
+
subject: "also from nu",
|
|
859
|
+
fromEmail: "nu@acme.test",
|
|
860
|
+
sentDate: now - 4,
|
|
861
|
+
internalDate: now - 4,
|
|
862
|
+
},
|
|
863
|
+
];
|
|
864
|
+
const ids: string[] = [];
|
|
865
|
+
for (const overrides of rows) {
|
|
866
|
+
const created = await repo.create(makeInput(acct, mbx, overrides));
|
|
867
|
+
ids.push(created.threadMessageId);
|
|
868
|
+
cleanup.push(() => repo.delete(acct, created.threadMessageId));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
let continuationToken: string | undefined = Buffer.from(
|
|
872
|
+
JSON.stringify({ s: now - 1, id: ids[1] }),
|
|
873
|
+
).toString("base64");
|
|
874
|
+
const walked: string[] = [];
|
|
875
|
+
do {
|
|
876
|
+
const page = await repo.searchByDate(
|
|
877
|
+
acct,
|
|
878
|
+
{ query: "nu" },
|
|
879
|
+
{
|
|
880
|
+
excludeDeleted: true,
|
|
881
|
+
mailboxIds: new Set([mbx]),
|
|
882
|
+
limit: 2,
|
|
883
|
+
continuationToken,
|
|
884
|
+
},
|
|
885
|
+
);
|
|
886
|
+
walked.push(...page.items.map((r) => r.subject ?? ""));
|
|
887
|
+
continuationToken = page.continuationToken;
|
|
888
|
+
if (continuationToken) {
|
|
889
|
+
const decoded = JSON.parse(
|
|
890
|
+
Buffer.from(continuationToken, "base64").toString("utf8"),
|
|
891
|
+
);
|
|
892
|
+
assert.equal(
|
|
893
|
+
decoded.v,
|
|
894
|
+
undefined,
|
|
895
|
+
"the session keeps its cursor shape",
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
} while (continuationToken);
|
|
899
|
+
|
|
900
|
+
assert.deepEqual(walked, ["from nu", "nu recap", "also from nu"]);
|
|
901
|
+
});
|
|
650
902
|
});
|
|
651
903
|
|
|
652
904
|
// ─── Native text-search semantics ─────────────────────────────────────────────
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
asc,
|
|
14
14
|
desc,
|
|
15
15
|
eq,
|
|
16
|
+
getTableColumns,
|
|
16
17
|
gt,
|
|
17
18
|
inArray,
|
|
18
19
|
lt,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
fromMatch,
|
|
32
33
|
isNarrowableTerm,
|
|
33
34
|
listIdMatch,
|
|
35
|
+
senderMatchRank,
|
|
34
36
|
subjectMatch,
|
|
35
37
|
} from "./thread-search-predicates.js";
|
|
36
38
|
|
|
@@ -57,6 +59,34 @@ export const clampThreadSearchLimit = (limit?: number): number => {
|
|
|
57
59
|
|
|
58
60
|
type DateCursor = { s: number; id: string };
|
|
59
61
|
type AccountCursor = { id: string };
|
|
62
|
+
type RankedDateCursor = { v: 2; r: number; s: number; id: string };
|
|
63
|
+
type SearchCursor = RankedDateCursor | (DateCursor & { v: 1 });
|
|
64
|
+
|
|
65
|
+
function encodeRankedDateCursor(
|
|
66
|
+
rank: number,
|
|
67
|
+
sentDate: number,
|
|
68
|
+
threadMessageId: string,
|
|
69
|
+
): string {
|
|
70
|
+
return Buffer.from(
|
|
71
|
+
JSON.stringify({ v: 2, r: rank, s: sentDate, id: threadMessageId }),
|
|
72
|
+
).toString("base64");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decodeSearchCursor(token: string): SearchCursor {
|
|
76
|
+
const decoded = decodeToken(token, "base64");
|
|
77
|
+
if (decoded.v === undefined) {
|
|
78
|
+
return { v: 1, ...decodeDateCursor(token) };
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
decoded.v !== 2 ||
|
|
82
|
+
typeof decoded.r !== "number" ||
|
|
83
|
+
typeof decoded.s !== "number" ||
|
|
84
|
+
typeof decoded.id !== "string"
|
|
85
|
+
) {
|
|
86
|
+
throw new BadRequestError("Invalid continuationToken");
|
|
87
|
+
}
|
|
88
|
+
return { v: 2, r: decoded.r, s: decoded.s, id: decoded.id };
|
|
89
|
+
}
|
|
60
90
|
|
|
61
91
|
function encodeDateCursor(sentDate: number, threadMessageId: string): string {
|
|
62
92
|
return Buffer.from(
|
|
@@ -144,6 +174,9 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
144
174
|
const mutedSender = (): SQL =>
|
|
145
175
|
sql`exists (select 1 from ${addressTable} where ${addressTable.accountConfigId} = ${threadMessageTable.accountConfigId} and ${addressTable.normalizedEmail} = lower(coalesce(${threadMessageTable.fromEmail}, '')) and json_extract(coalesce(nullif(${addressTable.flags}, ''), '{}'), '$.muted.value') = 1)`;
|
|
146
176
|
|
|
177
|
+
const queryTerms = (query: string | undefined): string[] =>
|
|
178
|
+
query ? query.split(/\s+/).filter(Boolean) : [];
|
|
179
|
+
|
|
147
180
|
// Translate SearchOptions into SQL conditions: subject/from/query as indexed
|
|
148
181
|
// text predicates, muted as a subquery over the sender's address, the rest as
|
|
149
182
|
// plain column equalities. A multi-word `query`
|
|
@@ -157,8 +190,7 @@ function buildSearchConditions(search: SearchOptions): SQL[] {
|
|
|
157
190
|
if (search.from) conditions.push(fromMatch(search.from));
|
|
158
191
|
|
|
159
192
|
if (search.query) {
|
|
160
|
-
const
|
|
161
|
-
for (const token of tokens) {
|
|
193
|
+
for (const token of queryTerms(search.query)) {
|
|
162
194
|
conditions.push(
|
|
163
195
|
sql`(${subjectMatch(token)} or ${fromMatch(token)} or ${bodyMatch(token)})`,
|
|
164
196
|
);
|
|
@@ -233,6 +265,18 @@ function sentDateCursorCond(
|
|
|
233
265
|
);
|
|
234
266
|
}
|
|
235
267
|
|
|
268
|
+
function rankedDateCursorCond(
|
|
269
|
+
order: "asc" | "desc",
|
|
270
|
+
rank: SQL<number>,
|
|
271
|
+
cursor: RankedDateCursor | null,
|
|
272
|
+
): SQL | undefined {
|
|
273
|
+
if (!cursor) return undefined;
|
|
274
|
+
return or(
|
|
275
|
+
lt(rank, cursor.r),
|
|
276
|
+
and(eq(rank, cursor.r), sentDateCursorCond(order, cursor)),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
236
280
|
// ─── Repository ──────────────────────────────────────────────────────────────
|
|
237
281
|
|
|
238
282
|
export class DrizzleThreadMessageRepository
|
|
@@ -528,8 +572,9 @@ export class DrizzleThreadMessageRepository
|
|
|
528
572
|
|
|
529
573
|
/**
|
|
530
574
|
* Cross-mailbox search for the unified listing's search mode. Same predicate
|
|
531
|
-
* builder
|
|
532
|
-
*
|
|
575
|
+
* builder as `searchByMailboxWindow`, with the mailbox equality swapped for
|
|
576
|
+
* the caller's scope set. Sender matches rank first, then date order; a
|
|
577
|
+
* cursor minted before the ranking finishes its session in date order. Matching runs in SQL over the
|
|
533
578
|
* whole scope, so a short page means the matches are exhausted.
|
|
534
579
|
*/
|
|
535
580
|
async searchByDate(
|
|
@@ -546,15 +591,23 @@ export class DrizzleThreadMessageRepository
|
|
|
546
591
|
const order = options?.order ?? "desc";
|
|
547
592
|
const limit = clampThreadSearchLimit(options?.limit);
|
|
548
593
|
const cursor = options?.continuationToken
|
|
549
|
-
?
|
|
594
|
+
? decodeSearchCursor(options.continuationToken)
|
|
550
595
|
: null;
|
|
596
|
+
const ranked = cursor?.v !== 1;
|
|
551
597
|
|
|
552
598
|
const mailboxCond = options?.mailboxIds?.size
|
|
553
599
|
? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
|
|
554
600
|
: undefined;
|
|
555
601
|
|
|
602
|
+
const senderRank = ranked
|
|
603
|
+
? senderMatchRank(queryTerms(search.query))
|
|
604
|
+
: sql<number>`0`;
|
|
605
|
+
|
|
556
606
|
const rows = await this.db
|
|
557
|
-
.select(
|
|
607
|
+
.select({
|
|
608
|
+
...getTableColumns(threadMessageTable),
|
|
609
|
+
senderRank,
|
|
610
|
+
})
|
|
558
611
|
.from(threadMessageTable)
|
|
559
612
|
.where(
|
|
560
613
|
and(
|
|
@@ -564,10 +617,13 @@ export class DrizzleThreadMessageRepository
|
|
|
564
617
|
? eq(threadMessageTable.isDeleted, false)
|
|
565
618
|
: undefined,
|
|
566
619
|
...buildSearchConditions(search),
|
|
567
|
-
|
|
620
|
+
cursor?.v === 1
|
|
621
|
+
? sentDateCursorCond(order, cursor)
|
|
622
|
+
: rankedDateCursorCond(order, senderRank, cursor),
|
|
568
623
|
),
|
|
569
624
|
)
|
|
570
625
|
.orderBy(
|
|
626
|
+
...(ranked ? [desc(senderRank)] : []),
|
|
571
627
|
order === "desc"
|
|
572
628
|
? desc(threadMessageTable.sentDate)
|
|
573
629
|
: asc(threadMessageTable.sentDate),
|
|
@@ -580,14 +636,20 @@ export class DrizzleThreadMessageRepository
|
|
|
580
636
|
items: rows.map(toItem),
|
|
581
637
|
continuationToken:
|
|
582
638
|
rows.length === limit && lastRow
|
|
583
|
-
?
|
|
639
|
+
? ranked
|
|
640
|
+
? encodeRankedDateCursor(
|
|
641
|
+
lastRow.senderRank,
|
|
642
|
+
lastRow.sentDate,
|
|
643
|
+
lastRow.threadMessageId,
|
|
644
|
+
)
|
|
645
|
+
: encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
|
|
584
646
|
: undefined,
|
|
585
647
|
};
|
|
586
648
|
}
|
|
587
649
|
|
|
588
650
|
/**
|
|
589
|
-
* Cross-mailbox narrowing for a rule back-apply. Same keyset cursor
|
|
590
|
-
* ordering as `
|
|
651
|
+
* Cross-mailbox narrowing for a rule back-apply. Same date keyset cursor
|
|
652
|
+
* and ordering as `listByDate`, with the terms combined under the caller's
|
|
591
653
|
* operator instead of the AND-only `SearchOptions` shape a search box needs.
|
|
592
654
|
* The terms run in SQL over the whole config, so a page is a page of
|
|
593
655
|
* narrowed rows and a rule for a sender that has been quiet for a month
|
|
@@ -91,3 +91,8 @@ export const bodyMatch = (term: string): SQL =>
|
|
|
91
91
|
// scan of one config's rows beats reading them all into the service (#459).
|
|
92
92
|
export const listIdMatch = (term: string): SQL =>
|
|
93
93
|
sql`${LIST_ID_FOLDED} like ${likePattern(term)} escape '\\'`;
|
|
94
|
+
|
|
95
|
+
export const senderMatchRank = (terms: readonly string[]): SQL<number> =>
|
|
96
|
+
terms.length === 0
|
|
97
|
+
? sql<number>`0`
|
|
98
|
+
: sql<number>`case when ${sql.join(terms.map(fromMatch), sql` or `)} then 1 else 0 end`;
|