@remit/drizzle-service 0.0.85 → 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
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.85",
3
+ "version": "0.0.87",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { AccountService } from "@remit/domain-enums";
4
+ import Database from "better-sqlite3";
5
+ import { drizzle } from "drizzle-orm/better-sqlite3";
6
+ import { AccountRepo } from "./repos/i4-account.js";
7
+ import { accountTable } from "./schema.js";
8
+ import {
9
+ applyMigration,
10
+ migrationJournal,
11
+ } from "./test-shipped-sqlite-schema.js";
12
+
13
+ const MIGRATION = "0029_account_synced_services";
14
+
15
+ const atPredecessor = (): Database.Database => {
16
+ const sqlite = new Database(":memory:");
17
+ for (const entry of migrationJournal()) {
18
+ if (entry.tag === MIGRATION) return sqlite;
19
+ applyMigration(sqlite, entry.tag);
20
+ }
21
+ throw new Error(`${MIGRATION} is not in the journal`);
22
+ };
23
+
24
+ describe("an account written before the service selection existed", () => {
25
+ test("comes out of the migration syncing mail only", async () => {
26
+ const sqlite = atPredecessor();
27
+ sqlite
28
+ .prepare(
29
+ `INSERT INTO account (
30
+ account_id, account_config_id, username, email, auth_type,
31
+ imap_host, imap_port, imap_tls, imap_start_tls, smtp_port,
32
+ is_active, connection_state, created_at, updated_at
33
+ ) VALUES ('existing', 'config', 'person', 'person@example.com',
34
+ 'password', 'imap.example.com', 993, 1, 0, 587, 1,
35
+ 'not_authenticated', 0, 0)`,
36
+ )
37
+ .run();
38
+
39
+ applyMigration(sqlite, MIGRATION);
40
+
41
+ const repo = new AccountRepo(
42
+ drizzle(sqlite, { schema: { accounts: accountTable } }) as never,
43
+ );
44
+ assert.deepEqual((await repo.get("existing")).syncedServices, [
45
+ AccountService.Mail,
46
+ ]);
47
+ sqlite.close();
48
+ });
49
+ });
@@ -0,0 +1,23 @@
1
+ import { accountRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { accountTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { AccountRepo } from "./i4-account.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ accountRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ accounts: accountTable,
14
+ });
15
+ close = closeDb;
16
+ return new AccountRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -58,6 +58,7 @@ describe("createSqliteCascadeDeleter", () => {
58
58
  username: "u",
59
59
  email: "u@example.com",
60
60
  authType: "password",
61
+ syncedServices: ["Mail"],
61
62
  imapHost: "imap.example.com",
62
63
  imapPort: 993,
63
64
  imapTls: true,
@@ -56,6 +56,7 @@ describe("runDrizzleCascadeDelete", () => {
56
56
  username: "u",
57
57
  email: "u@example.com",
58
58
  authType: "password",
59
+ syncedServices: ["Mail"],
59
60
  imapHost: "imap.example.com",
60
61
  imapPort: 993,
61
62
  imapTls: true,
@@ -7,7 +7,7 @@ import type {
7
7
  ResultList,
8
8
  UpdateAccountInput,
9
9
  } from "@remit/data-ports";
10
- import { SyncPhase } from "@remit/domain-enums";
10
+ import { AccountService, SyncPhase } from "@remit/domain-enums";
11
11
  import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm";
12
12
  import type { Db } from "../db.js";
13
13
  import { NotFoundError } from "../error.js";
@@ -39,6 +39,7 @@ export function rowToAccount(
39
39
  username: row.username,
40
40
  email: row.email,
41
41
  authType: row.authType as AccountItem["authType"],
42
+ syncedServices: row.syncedServices as AccountItem["syncedServices"],
42
43
  passwordHash: row.passwordHash ?? undefined,
43
44
  oauthRefreshTokenHash: row.oauthRefreshTokenHash ?? undefined,
44
45
  oauthTokenUpdatedAt: row.oauthTokenUpdatedAt ?? undefined,
@@ -81,6 +82,7 @@ export class AccountRepo implements IAccountRepository {
81
82
  username: input.username,
82
83
  email: input.email,
83
84
  authType: input.authType ?? "password",
85
+ syncedServices: input.syncedServices ?? [AccountService.Mail],
84
86
  passwordHash: input.passwordHash,
85
87
  oauthRefreshTokenHash: input.oauthRefreshTokenHash,
86
88
  oauthTokenUpdatedAt: input.oauthTokenUpdatedAt,
@@ -144,6 +146,8 @@ export class AccountRepo implements IAccountRepository {
144
146
  if (input.username !== undefined) updates.username = input.username;
145
147
  if (input.email !== undefined) updates.email = input.email;
146
148
  if (input.authType !== undefined) updates.authType = input.authType;
149
+ if (input.syncedServices !== undefined)
150
+ updates.syncedServices = input.syncedServices;
147
151
  if (input.passwordHash !== undefined)
148
152
  updates.passwordHash = input.passwordHash;
149
153
  if (input.oauthRefreshTokenHash !== undefined)
@@ -20,9 +20,9 @@ describe("reconciling one message's addresses at the moment it moves", () => {
20
20
  `INSERT OR IGNORE INTO account (
21
21
  account_id, account_config_id, username, email, imap_host,
22
22
  imap_port, imap_tls, imap_start_tls, smtp_port, is_active,
23
- connection_state, created_at, updated_at
23
+ connection_state, synced_services, created_at, updated_at
24
24
  ) VALUES (?, ?, 'user', 'user@example.com', 'imap.example.com',
25
- 993, 1, 0, 465, 1, 'disconnected', 0, 0)`,
25
+ 993, 1, 0, 465, 1, 'disconnected', '["Mail"]', 0, 0)`,
26
26
  )
27
27
  .run(accountId, CONFIG);
28
28
  };
@@ -82,6 +82,7 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
82
82
  imapStartTls: false,
83
83
  isActive: true,
84
84
  connectionState: "authenticated",
85
+ syncedServices: ["Mail"],
85
86
  createdAt: now,
86
87
  updatedAt: now,
87
88
  } as never);
@@ -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));
@@ -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 tokens = search.query.split(/\s+/).filter(Boolean);
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 and keyset cursor as `searchByMailboxWindow`, with the mailbox
532
- * equality swapped for the caller's scope set. Matching runs in SQL over the
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
- ? decodeDateCursor(options.continuationToken)
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
- sentDateCursorCond(order, cursor),
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
- ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
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 and
590
- * ordering as `searchByDate`, with the terms combined under the caller's
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`;