@remit/drizzle-service 0.0.77 → 0.0.79

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.77",
3
+ "version": "0.0.79",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import Database from "better-sqlite3";
4
+ import {
5
+ applyMigration,
6
+ migrationJournal,
7
+ } from "./test-shipped-sqlite-schema.js";
8
+
9
+ /**
10
+ * The data migration that clears the `message.created` backlog (reader#1063),
11
+ * against a database that already holds those rows.
12
+ *
13
+ * Every other outbox test starts from an empty table, so the case that matters
14
+ * — an instance upgrading with 30,906 undrained rows the drain filters out and
15
+ * the column's type no longer admits — is only reachable by running the shipped
16
+ * migrations over a table populated at the point the upgrade finds it.
17
+ */
18
+
19
+ const CLEANUP_TAG = "0022_drop_message_created_outbox";
20
+
21
+ const seed = (sqlite: Database.Database, event: string, id: string): void => {
22
+ sqlite
23
+ .prepare(
24
+ `INSERT INTO outbox (id, message_id, event, payload, created_at)
25
+ VALUES (?, ?, ?, ?, ?)`,
26
+ )
27
+ .run(id, `message-${id}`, event, JSON.stringify({ messageId: id }), 1);
28
+ };
29
+
30
+ const unprocessed = (sqlite: Database.Database): string[] =>
31
+ (
32
+ sqlite
33
+ .prepare(
34
+ "SELECT event FROM outbox WHERE processed_at IS NULL ORDER BY event",
35
+ )
36
+ .all() as Array<{ event: string }>
37
+ ).map((row) => row.event);
38
+
39
+ const upgradedDatabase = (): Database.Database => {
40
+ const entries = [...migrationJournal()].sort(
41
+ (left, right) => left.idx - right.idx,
42
+ );
43
+ const cleanup = entries.findIndex((entry) => entry.tag === CLEANUP_TAG);
44
+ assert.notEqual(cleanup, -1, `${CLEANUP_TAG} is not in the journal`);
45
+
46
+ const sqlite = new Database(":memory:");
47
+ for (const entry of entries.slice(0, cleanup)) {
48
+ applyMigration(sqlite, entry.tag);
49
+ }
50
+
51
+ seed(sqlite, "message.created", "created-1");
52
+ seed(sqlite, "message.created", "created-2");
53
+ seed(sqlite, "message.body_synced", "synced-1");
54
+ seed(sqlite, "message.moved", "moved-1");
55
+
56
+ for (const entry of entries.slice(cleanup)) {
57
+ applyMigration(sqlite, entry.tag);
58
+ }
59
+ return sqlite;
60
+ };
61
+
62
+ describe("the message.created outbox cleanup", () => {
63
+ test("clears the rows an upgrading instance already has", () => {
64
+ const sqlite = upgradedDatabase();
65
+
66
+ const left = sqlite
67
+ .prepare("SELECT COUNT(*) AS n FROM outbox WHERE event = ?")
68
+ .get("message.created") as { n: number };
69
+
70
+ assert.equal(left.n, 0);
71
+ sqlite.close();
72
+ });
73
+
74
+ test("leaves the drained kinds alone", () => {
75
+ const sqlite = upgradedDatabase();
76
+
77
+ assert.deepEqual(unprocessed(sqlite), [
78
+ "message.body_synced",
79
+ "message.moved",
80
+ ]);
81
+ sqlite.close();
82
+ });
83
+ });
@@ -52,7 +52,7 @@ describe("DrizzleMessageRepository (sqlite)", () => {
52
52
  await close();
53
53
  });
54
54
 
55
- test("create returns a MessageItem and writes one outbox row atomically", async () => {
55
+ test("create returns a MessageItem and writes no outbox row", async () => {
56
56
  const item = await repo.create(BASE_INPUT);
57
57
  assert.equal(item.messageId, MESSAGE_ID);
58
58
  assert.equal(item.status, "active");
@@ -62,9 +62,11 @@ describe("DrizzleMessageRepository (sqlite)", () => {
62
62
  .select()
63
63
  .from(outboxTable)
64
64
  .where(eq(outboxTable.messageId, MESSAGE_ID));
65
- assert.equal(rows.length, 1);
66
- assert.equal(rows[0].event, "message.created");
67
- assert.deepEqual(rows[0].payload, { messageId: MESSAGE_ID });
65
+ assert.equal(
66
+ rows.length,
67
+ 0,
68
+ "a message with no body yet has nothing to index",
69
+ );
68
70
  });
69
71
 
70
72
  test("boolean and json columns round-trip", async () => {
@@ -120,7 +122,7 @@ describe("DrizzleMessageRepository (sqlite)", () => {
120
122
  assert.equal(afterUpdate.placementVerdict?.action, "MoveToInbox");
121
123
  });
122
124
 
123
- test("duplicate messageId throws CreateFailedConflictError and rolls back the outbox row", async () => {
125
+ test("duplicate messageId throws CreateFailedConflictError and appends no outbox row", async () => {
124
126
  const before = await db
125
127
  .select()
126
128
  .from(outboxTable)
@@ -44,7 +44,7 @@ describe("DrizzleMessageRepository", () => {
44
44
  await stop();
45
45
  });
46
46
 
47
- describe("create — writes message + outbox row in one transaction", () => {
47
+ describe("create — writes the message row only", () => {
48
48
  test("creates a message and returns MessageItem", async () => {
49
49
  const item = await messageRepo.create(BASE_MESSAGE_INPUT);
50
50
  assert.equal(item.messageId, MESSAGE_ID);
@@ -56,16 +56,14 @@ describe("DrizzleMessageRepository", () => {
56
56
  assert.ok(typeof item.updatedAt === "number");
57
57
  });
58
58
 
59
- test("writes an outbox row in the same transaction", async () => {
59
+ test("writes no outbox row there is nothing to index before the body", async () => {
60
60
  const { outboxTable } = await import("../schema/message-data.js");
61
61
  const { eq } = await import("drizzle-orm");
62
62
  const rows = await db
63
63
  .select()
64
64
  .from(outboxTable)
65
65
  .where(eq(outboxTable.messageId, MESSAGE_ID));
66
- assert.ok(rows.length >= 1, "should have at least 1 outbox row");
67
- assert.equal(rows[0].event, "message.created");
68
- assert.deepStrictEqual(rows[0].payload, { messageId: MESSAGE_ID });
66
+ assert.equal(rows.length, 0);
69
67
  });
70
68
 
71
69
  test("duplicate messageId throws CreateFailedConflictError and writes no extra outbox row", async () => {
@@ -107,7 +107,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
107
107
  * the search-index worker relays a search-index REMOVE and the vectors are
108
108
  * dropped.
109
109
  */
110
- export const MESSAGE_REMOVED_EVENT = "message.removed";
110
+ export const MESSAGE_REMOVED_EVENT = "message.removed" as const;
111
111
 
112
112
  export type SubtreeDb = Pick<Db<Record<string, unknown>>, "delete" | "insert">;
113
113
 
@@ -207,22 +207,15 @@ export class DrizzleMessageRepository implements IMessageRepository {
207
207
  updatedAt: now,
208
208
  };
209
209
 
210
- // Faithful to ElectroDB message.create: a duplicate messageId throws
211
- // CreateFailedConflictError. The plain insert raises a unique-constraint
212
- // violation, which rolls back the transaction so NO outbox row is
213
- // written; we surface it as the domain conflict error.
210
+ // Faithful to ElectroDB message.create: a duplicate messageId raises a
211
+ // unique-constraint violation, surfaced as the domain conflict error.
212
+ //
213
+ // No outbox event. A freshly created message has neither a body nor a
214
+ // threadMessage yet, so there is nothing to index — the search-index
215
+ // relay never drained a creation event, and the rows accumulated forever
216
+ // (reader#1063). Body-sync appends the event once there is content.
214
217
  try {
215
- await runInTransaction(this.db, async (tx) => {
216
- await tx.insert(messageTable).values(row);
217
-
218
- await tx.insert(outboxTable).values({
219
- id: randomUUID(),
220
- messageId: input.messageId,
221
- event: "message.created",
222
- payload: { messageId: input.messageId },
223
- createdAt: new Date(),
224
- });
225
- });
218
+ await this.db.insert(messageTable).values(row);
226
219
  } catch (error) {
227
220
  if (isUniqueViolation(error)) {
228
221
  throw new CreateFailedConflictError("Message", input);
@@ -21,7 +21,7 @@ import { runInTransaction, serializeSqliteWrites } from "../tx.js";
21
21
  const row = (id: string) => ({
22
22
  id,
23
23
  messageId: `msg-${id}`,
24
- event: "message.body_synced",
24
+ event: "message.body_synced" as const,
25
25
  payload: { messageId: `msg-${id}` },
26
26
  createdAt: new Date(),
27
27
  });
@@ -0,0 +1,277 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import type { CreateThreadMessageInput } from "@remit/data-ports";
4
+ import { threadMessageTable } from "../schema/thread-message.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import {
7
+ DrizzleThreadMessageRepository,
8
+ THREAD_SEARCH_MAX_LIMIT,
9
+ } from "./thread-message.js";
10
+
11
+ // `listByFieldTerms` on sqlite (#459): the terms decide inside the query, so a
12
+ // rule for a sender that has been quiet for months reaches its mail however
13
+ // much newer mail sits above it. Filtering a date-ordered page instead answers
14
+ // "matches among the newest N", which is the defect this pins.
15
+
16
+ const ACCOUNT = "acct-terms";
17
+ const OTHER_ACCOUNT = "acct-terms-other";
18
+ const MAILBOX = "mbx-terms";
19
+ const ARCHIVE = "mbx-terms-archive";
20
+
21
+ // One more than the ceiling a back-apply reads with, so the single old match
22
+ // sits strictly below any window the newest page could cover.
23
+ const NEWER_NOISE = THREAD_SEARCH_MAX_LIMIT + 1;
24
+
25
+ const OLD_DATE = 1_600_000_000_000;
26
+
27
+ function makeInput(
28
+ overrides: Partial<CreateThreadMessageInput> = {},
29
+ ): CreateThreadMessageInput {
30
+ return {
31
+ accountConfigId: ACCOUNT,
32
+ threadId: `t-${Math.random().toString(36).slice(2)}`,
33
+ messageId: `m-${Math.random().toString(36).slice(2)}`,
34
+ mailboxId: MAILBOX,
35
+ uid: 1,
36
+ referenceOrder: 0,
37
+ internalDate: OLD_DATE,
38
+ sentDate: OLD_DATE,
39
+ isRead: false,
40
+ isDeleted: false,
41
+ hasAttachment: false,
42
+ hasStars: false,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () => {
48
+ let close: () => Promise<void>;
49
+ let repo: DrizzleThreadMessageRepository;
50
+
51
+ before(async () => {
52
+ const created = await createSqliteTestDb(
53
+ { threadMessage: threadMessageTable },
54
+ { searchIndex: true },
55
+ );
56
+ close = created.close;
57
+ repo = new DrizzleThreadMessageRepository(created.db);
58
+
59
+ await repo.create(
60
+ makeInput({
61
+ messageId: "quiet-sender",
62
+ subject: "Your March statement",
63
+ fromName: "Statements",
64
+ fromEmail: "noreply@bank.example",
65
+ listId: "statements.bank.example",
66
+ }),
67
+ );
68
+ await repo.create(
69
+ makeInput({
70
+ messageId: "accented",
71
+ subject: "CAFÉ closing early",
72
+ fromName: "Café",
73
+ fromEmail: "hello@paris.example",
74
+ sentDate: OLD_DATE + 9_000_000,
75
+ internalDate: OLD_DATE + 9_000_000,
76
+ }),
77
+ );
78
+ for (let index = 0; index < NEWER_NOISE; index++) {
79
+ await repo.create(
80
+ makeInput({
81
+ messageId: `noise-${index}`,
82
+ subject: `Daily digest ${index}`,
83
+ fromName: "Digest",
84
+ fromEmail: "digest@other.example",
85
+ sentDate: OLD_DATE + 1 + index,
86
+ internalDate: OLD_DATE + 1 + index,
87
+ }),
88
+ );
89
+ }
90
+ });
91
+
92
+ after(async () => {
93
+ await close();
94
+ });
95
+
96
+ test("finds a match older than a whole page of newer non-matching mail", async () => {
97
+ const result = await repo.listByFieldTerms(
98
+ ACCOUNT,
99
+ [{ field: "sender", contains: "bank.example" }],
100
+ { limit: THREAD_SEARCH_MAX_LIMIT },
101
+ );
102
+
103
+ assert.deepEqual(
104
+ result.items.map((item) => item.messageId),
105
+ ["quiet-sender"],
106
+ );
107
+ assert.equal(result.continuationToken, undefined);
108
+ });
109
+
110
+ test("a page is a page of matches, not a page of rows", async () => {
111
+ const result = await repo.listByFieldTerms(
112
+ ACCOUNT,
113
+ [{ field: "sender", contains: "bank.example" }],
114
+ { limit: 5 },
115
+ );
116
+
117
+ assert.equal(result.items.length, 1);
118
+ });
119
+
120
+ test("matches the subject and the List-Id columns", async () => {
121
+ const bySubject = await repo.listByFieldTerms(ACCOUNT, [
122
+ { field: "subject", contains: "march statement" },
123
+ ]);
124
+ const byListId = await repo.listByFieldTerms(ACCOUNT, [
125
+ { field: "listId", contains: "statements.bank.example" },
126
+ ]);
127
+
128
+ assert.deepEqual(
129
+ bySubject.items.map((item) => item.messageId),
130
+ ["quiet-sender"],
131
+ );
132
+ assert.deepEqual(
133
+ byListId.items.map((item) => item.messageId),
134
+ ["quiet-sender"],
135
+ );
136
+ });
137
+
138
+ test("`and` requires every term, `or` any of them", async () => {
139
+ const terms = [
140
+ { field: "sender", contains: "bank.example" },
141
+ { field: "subject", contains: "daily digest 0" },
142
+ ] as const;
143
+
144
+ const conjunction = await repo.listByFieldTerms(ACCOUNT, terms, {
145
+ operator: "and",
146
+ });
147
+ const disjunction = await repo.listByFieldTerms(ACCOUNT, terms, {
148
+ operator: "or",
149
+ });
150
+
151
+ assert.deepEqual(conjunction.items, []);
152
+ assert.deepEqual(disjunction.items.map((item) => item.messageId).sort(), [
153
+ "noise-0",
154
+ "quiet-sender",
155
+ ]);
156
+ });
157
+
158
+ // Below the trigram floor the predicate is the folded LIKE, and sqlite's
159
+ // lower() folds ASCII only — `é` never matches a stored `CAFÉ`. Applying it
160
+ // anyway would drop a row the caller's own matcher accepts, which is #459
161
+ // again for that clause shape, so a short non-ASCII term narrows nothing.
162
+ test("drops a short accented term rather than missing the row it should match", async () => {
163
+ const result = await repo.listByFieldTerms(
164
+ ACCOUNT,
165
+ [{ field: "subject", contains: "é" }],
166
+ { limit: THREAD_SEARCH_MAX_LIMIT },
167
+ );
168
+
169
+ assert.ok(
170
+ result.items.some((item) => item.messageId === "accented"),
171
+ "the accented row survives",
172
+ );
173
+ assert.ok(result.items.length > 1, "the term narrowed nothing at all");
174
+ });
175
+
176
+ test("keeps narrowing on the other terms of an `and` around a dropped one", async () => {
177
+ const result = await repo.listByFieldTerms(
178
+ ACCOUNT,
179
+ [
180
+ { field: "subject", contains: "é" },
181
+ { field: "sender", contains: "bank.example" },
182
+ ],
183
+ { operator: "and" },
184
+ );
185
+
186
+ assert.deepEqual(
187
+ result.items.map((item) => item.messageId),
188
+ ["quiet-sender"],
189
+ );
190
+ });
191
+
192
+ test("drops the whole narrowing when an `or` branch cannot be evaluated", async () => {
193
+ const result = await repo.listByFieldTerms(
194
+ ACCOUNT,
195
+ [
196
+ { field: "subject", contains: "é" },
197
+ { field: "sender", contains: "bank.example" },
198
+ ],
199
+ { operator: "or", limit: 5 },
200
+ );
201
+
202
+ const found = result.items.map((item) => item.messageId);
203
+ assert.ok(found.includes("accented"), "the dropped branch keeps its rows");
204
+ assert.ok(
205
+ found.some((messageId) => messageId.startsWith("noise-")),
206
+ "a narrowed `or` would have excluded these",
207
+ );
208
+ });
209
+
210
+ test("still narrows on a short ASCII term, which lower() folds correctly", async () => {
211
+ const result = await repo.listByFieldTerms(ACCOUNT, [
212
+ { field: "sender", contains: "k." },
213
+ ]);
214
+
215
+ assert.deepEqual(
216
+ result.items.map((item) => item.messageId),
217
+ ["quiet-sender"],
218
+ );
219
+ });
220
+
221
+ test("no terms narrows nothing", async () => {
222
+ const result = await repo.listByFieldTerms(ACCOUNT, [], { limit: 3 });
223
+
224
+ assert.equal(result.items.length, 3);
225
+ assert.ok(result.continuationToken, "more rows remain");
226
+ });
227
+
228
+ test("stays inside the account and skips deleted rows on request", async () => {
229
+ await repo.create(
230
+ makeInput({
231
+ accountConfigId: OTHER_ACCOUNT,
232
+ messageId: "other-account",
233
+ fromEmail: "noreply@bank.example",
234
+ }),
235
+ );
236
+ await repo.create(
237
+ makeInput({
238
+ messageId: "deleted-match",
239
+ mailboxId: ARCHIVE,
240
+ fromEmail: "noreply@bank.example",
241
+ isDeleted: true,
242
+ }),
243
+ );
244
+
245
+ const result = await repo.listByFieldTerms(
246
+ ACCOUNT,
247
+ [{ field: "sender", contains: "bank.example" }],
248
+ { excludeDeleted: true },
249
+ );
250
+
251
+ assert.deepEqual(
252
+ result.items.map((item) => item.messageId),
253
+ ["quiet-sender"],
254
+ );
255
+ });
256
+
257
+ test("pages the matches with a keyset cursor", async () => {
258
+ const first = await repo.listByFieldTerms(
259
+ ACCOUNT,
260
+ [{ field: "sender", contains: "other.example" }],
261
+ { limit: 2 },
262
+ );
263
+ assert.equal(first.items.length, 2);
264
+ assert.ok(first.continuationToken);
265
+
266
+ const second = await repo.listByFieldTerms(
267
+ ACCOUNT,
268
+ [{ field: "sender", contains: "other.example" }],
269
+ { limit: 2, continuationToken: first.continuationToken },
270
+ );
271
+
272
+ const overlap = second.items.filter((item) =>
273
+ first.items.some((seen) => seen.messageId === item.messageId),
274
+ );
275
+ assert.deepEqual(overlap, [], "pages do not repeat a row");
276
+ });
277
+ });
@@ -3,6 +3,7 @@ import type {
3
3
  IThreadMessageRepository,
4
4
  ResultList,
5
5
  SearchOptions,
6
+ ThreadMessageFieldTerm,
6
7
  ThreadMessageItem,
7
8
  UpdateThreadMessageInput,
8
9
  } from "@remit/data-ports";
@@ -24,7 +25,12 @@ import { NotFoundError } from "../error.js";
24
25
  import { deterministicBase36Id } from "../id.js";
25
26
  import { decodeToken } from "../pagination.js";
26
27
  import { threadMessageTable } from "../schema/thread-message.js";
27
- import { fromMatch, subjectMatch } from "./thread-search-predicates.js";
28
+ import {
29
+ fromMatch,
30
+ isNarrowableTerm,
31
+ listIdMatch,
32
+ subjectMatch,
33
+ } from "./thread-search-predicates.js";
28
34
 
29
35
  export const deriveThreadMessageId = (
30
36
  threadId: string,
@@ -158,6 +164,30 @@ function buildSearchConditions(search: SearchOptions): SQL[] {
158
164
  return conditions;
159
165
  }
160
166
 
167
+ const fieldTermCondition = (term: ThreadMessageFieldTerm): SQL => {
168
+ if (term.field === "subject") return subjectMatch(term.contains);
169
+ if (term.field === "listId") return listIdMatch(term.contains);
170
+ return fromMatch(term.contains);
171
+ };
172
+
173
+ // Combine the caller's terms into one condition, or `undefined` when there are
174
+ // none — an empty set narrows nothing, which is what both operators mean here.
175
+ //
176
+ // A term this engine cannot evaluate faithfully ({@link isNarrowableTerm}) is
177
+ // left out rather than emitted: under `and` a missing conjunct only widens the
178
+ // candidate set, which the caller refines anyway, but under `or` a missing
179
+ // branch loses matches outright, so the whole narrowing goes.
180
+ function buildFieldTermCondition(
181
+ terms: readonly ThreadMessageFieldTerm[],
182
+ operator: "and" | "or",
183
+ ): SQL | undefined {
184
+ const narrowable = terms.filter((term) => isNarrowableTerm(term.contains));
185
+ if (operator === "or" && narrowable.length !== terms.length) return undefined;
186
+ const conditions = narrowable.map(fieldTermCondition);
187
+ if (conditions.length === 0) return undefined;
188
+ return operator === "or" ? or(...conditions) : and(...conditions);
189
+ }
190
+
161
191
  // Keyset cursor over (sent_date, thread_message_id). `desc` walks newest→oldest,
162
192
  // `asc` oldest→newest; the id tiebreak keeps paging stable across equal dates.
163
193
  function sentDateCursorCond(
@@ -530,6 +560,62 @@ export class DrizzleThreadMessageRepository
530
560
  };
531
561
  }
532
562
 
563
+ /**
564
+ * Cross-mailbox narrowing for a rule back-apply. Same keyset cursor and
565
+ * ordering as `searchByDate`, with the terms combined under the caller's
566
+ * operator instead of the AND-only `SearchOptions` shape a search box needs.
567
+ * The terms run in SQL over the whole config, so a page is a page of
568
+ * narrowed rows and a rule for a sender that has been quiet for a month
569
+ * reaches its mail (#459).
570
+ */
571
+ async listByFieldTerms(
572
+ accountConfigId: string,
573
+ terms: readonly ThreadMessageFieldTerm[],
574
+ options?: {
575
+ operator?: "and" | "or";
576
+ order?: "asc" | "desc";
577
+ limit?: number;
578
+ continuationToken?: string;
579
+ excludeDeleted?: boolean;
580
+ },
581
+ ): Promise<ResultList<ThreadMessageItem>> {
582
+ const order = options?.order ?? "desc";
583
+ const limit = clampThreadSearchLimit(options?.limit);
584
+ const cursor = options?.continuationToken
585
+ ? decodeDateCursor(options.continuationToken)
586
+ : null;
587
+
588
+ const rows = await this.db
589
+ .select()
590
+ .from(threadMessageTable)
591
+ .where(
592
+ and(
593
+ eq(threadMessageTable.accountConfigId, accountConfigId),
594
+ options?.excludeDeleted
595
+ ? eq(threadMessageTable.isDeleted, false)
596
+ : undefined,
597
+ buildFieldTermCondition(terms, options?.operator ?? "and"),
598
+ sentDateCursorCond(order, cursor),
599
+ ),
600
+ )
601
+ .orderBy(
602
+ order === "desc"
603
+ ? desc(threadMessageTable.sentDate)
604
+ : asc(threadMessageTable.sentDate),
605
+ asc(threadMessageTable.threadMessageId),
606
+ )
607
+ .limit(limit);
608
+
609
+ const lastRow = rows[rows.length - 1];
610
+ return {
611
+ items: rows.map(toItem),
612
+ continuationToken:
613
+ rows.length === limit && lastRow
614
+ ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
615
+ : undefined,
616
+ };
617
+ }
618
+
533
619
  /**
534
620
  * COUNT of matching CONVERSATIONS over the SAME predicate as the
535
621
  * cross-account listings, across the caller's mailbox scope.
@@ -31,11 +31,29 @@ const ftsPhrase = (term: string): string => `"${term.replace(/"/g, '""')}"`;
31
31
  // index floor.
32
32
  const isTrigramIndexable = (term: string): boolean => [...term].length >= 3;
33
33
 
34
+ const isAscii = (term: string): boolean =>
35
+ [...term].every((char) => (char.codePointAt(0) ?? 0) < 128);
36
+
37
+ /**
38
+ * Whether a term can be pushed into the query without dropping a row the
39
+ * caller's own matcher would accept.
40
+ *
41
+ * Below the trigram floor the predicate is the folded LIKE, and SQLite's
42
+ * `lower()` folds ASCII only: `é` never matches a stored `CAFÉ`. For a search
43
+ * box that is the accepted C10 difference between a short term and an indexed
44
+ * one. For a NARROWING it is not — a narrowing that misses is the #459 defect
45
+ * again, one clause shape at a time — so such a term is not narrowable at all
46
+ * and the caller widens instead.
47
+ */
48
+ export const isNarrowableTerm = (term: string): boolean =>
49
+ isTrigramIndexable(term) || isAscii(term);
50
+
34
51
  const ftsRowidMatch = (matchExpr: string): SQL =>
35
52
  sql`"thread_message"."rowid" in (select "rowid" from "thread_message_fts" where "thread_message_fts" match ${matchExpr})`;
36
53
 
37
54
  const SUBJECT_FOLDED = sql`lower(coalesce(subject, ''))`;
38
55
  const FROM_FOLDED = sql`lower(coalesce(from_name, '') || ' ' || coalesce(from_email, ''))`;
56
+ const LIST_ID_FOLDED = sql`lower(coalesce(list_id, ''))`;
39
57
 
40
58
  const likePattern = (term: string): SQL =>
41
59
  sql`'%' || lower(${escapeLike(term)}) || '%'`;
@@ -49,3 +67,9 @@ export const fromMatch = (term: string): SQL =>
49
67
  isTrigramIndexable(term)
50
68
  ? ftsRowidMatch(`sender : ${ftsPhrase(term)}`)
51
69
  : sql`${FROM_FOLDED} like ${likePattern(term)} escape '\\'`;
70
+
71
+ // The FTS index carries subject and sender only, so a List-Id term is always
72
+ // the folded LIKE scan. It is the narrowing half of a rule back-apply, where a
73
+ // scan of one config's rows beats reading them all into the service (#459).
74
+ export const listIdMatch = (term: string): SQL =>
75
+ sql`${LIST_ID_FOLDED} like ${likePattern(term)} escape '\\'`;
@@ -22,7 +22,7 @@ describe("DrizzleUnitOfWork", () => {
22
22
  const MAILBOX_ID = "00000000-0000-0000-2222-000000000002";
23
23
  const NOW = 1700000000000;
24
24
 
25
- const writeEnvelopeAndMessage = async (
25
+ const writeEnvelopeMessageAndBody = async (
26
26
  repos: Parameters<Parameters<DrizzleUnitOfWork["transaction"]>[0]>[0],
27
27
  ) => {
28
28
  await repos.envelope.upsertEnvelope({
@@ -43,6 +43,10 @@ describe("DrizzleUnitOfWork", () => {
43
43
  envelopeId: deriveEnvelopeId(MESSAGE_ID),
44
44
  rootBodyPartId: deriveRootBodyPartId(MESSAGE_ID),
45
45
  });
46
+ // Body-sync appends the transactional-outbox row; the create emits none.
47
+ await repos.message.update(MESSAGE_ID, {
48
+ bodyStorageKey: "body/hello.json",
49
+ });
46
50
  };
47
51
 
48
52
  const rows = async () => {
@@ -74,7 +78,7 @@ describe("DrizzleUnitOfWork", () => {
74
78
  await assert.rejects(
75
79
  () =>
76
80
  unitOfWork.transaction(async (repos) => {
77
- await writeEnvelopeAndMessage(repos);
81
+ await writeEnvelopeMessageAndBody(repos);
78
82
  // A later write in the set fails after the envelope, message and its
79
83
  // transactional-outbox row have already been written.
80
84
  throw new Error("thread write failed");
@@ -93,13 +97,13 @@ describe("DrizzleUnitOfWork", () => {
93
97
  });
94
98
 
95
99
  test("a successful transaction commits the data rows and the outbox row", async () => {
96
- await unitOfWork.transaction(writeEnvelopeAndMessage);
100
+ await unitOfWork.transaction(writeEnvelopeMessageAndBody);
97
101
 
98
102
  const { envelopes, messages, outbox } = await rows();
99
103
  assert.equal(envelopes.length, 1);
100
104
  assert.equal(messages.length, 1);
101
105
  assert.equal(outbox.length, 1);
102
- assert.equal(outbox[0].event, "message.created");
106
+ assert.equal(outbox[0].event, "message.body_synced");
103
107
  assert.deepStrictEqual(outbox[0].payload, { messageId: MESSAGE_ID });
104
108
  });
105
109
  });
@@ -1,7 +1,7 @@
1
1
  import * as entities from "@remit/drizzle-sqlite-schema";
2
- import { outboxTable } from "./outbox.js";
2
+ import { OUTBOX_EVENTS, type OutboxEvent, outboxTable } from "./outbox.js";
3
3
 
4
- export { outboxTable };
4
+ export { OUTBOX_EVENTS, type OutboxEvent, outboxTable };
5
5
 
6
6
  const bodyPartContentTable = entities.bodyPartContents;
7
7
  const bodyPartParameterTable = entities.bodyPartParameters;
@@ -10,12 +10,27 @@ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
10
10
  * Exported raw for committed-migration generation (schema-full-sqlite) as well
11
11
  * as for the repos and the drain logic.
12
12
  */
13
+ /**
14
+ * The event vocabulary a producer may append. Every kind here is drained by the
15
+ * search-index relay (`DRAIN_EVENTS`); a kind nothing drains accumulates
16
+ * undrained rows forever, which is what `message.created` did (reader#1063).
17
+ * The column carries the union so a new producer cannot invent a kind without
18
+ * adding it here, where the drain-coverage test picks it up.
19
+ */
20
+ export const OUTBOX_EVENTS = [
21
+ "message.body_synced",
22
+ "message.moved",
23
+ "message.removed",
24
+ ] as const;
25
+
26
+ export type OutboxEvent = (typeof OUTBOX_EVENTS)[number];
27
+
13
28
  export const outboxTable = sqliteTable(
14
29
  "outbox",
15
30
  {
16
31
  id: text("id").primaryKey(),
17
32
  messageId: text("message_id").notNull(),
18
- event: text("event").notNull(),
33
+ event: text("event", { enum: OUTBOX_EVENTS }).notNull(),
19
34
  payload: text("payload", { mode: "json" }).notNull(),
20
35
  createdAt: integer("created_at", { mode: "timestamp_ms" })
21
36
  .$defaultFn(() => new Date())