@remit/backend 0.0.24 → 0.0.25

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/backend",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -157,14 +157,19 @@ export const OrganizeOperations: Record<
157
157
  const client = await getClient();
158
158
  await assertAccount(client, accountId, accountConfigId, "read");
159
159
 
160
- const messageIds = await matchOrganize(
160
+ const { messageIds, semanticUnavailable } = await matchOrganize(
161
161
  buildOrganizeMatchDeps(client),
162
162
  accountConfigId,
163
163
  predicateFromInput(input),
164
164
  ORGANIZE_MATCH_LIMIT,
165
165
  );
166
166
 
167
- return { matchedCount: messageIds.length, messageIds };
167
+ const response: OrganizePreviewResponse = {
168
+ matchedCount: messageIds.length,
169
+ messageIds,
170
+ };
171
+ if (semanticUnavailable) response.semanticUnavailable = true;
172
+ return response;
168
173
  },
169
174
  };
170
175
 
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
3
  import { FilterMatchOperator } from "@remit/domain-enums";
4
4
  import type {
5
5
  AnchorPayload,
@@ -11,9 +11,17 @@ import type { RemitClient } from "./dynamodb.js";
11
11
  import {
12
12
  applyOrganize,
13
13
  matchOrganize,
14
+ type OrganizeCandidate,
14
15
  type OrganizeMatchDeps,
15
16
  type OrganizePredicate,
16
17
  } from "./organize.js";
18
+ import { _resetSemanticCapabilityForTest } from "./semantic-capability.js";
19
+
20
+ const moduleNotFound = (): Error => {
21
+ const error = new Error("Cannot find package 'sqlite-vec' imported from …");
22
+ (error as Error & { code: string }).code = "ERR_MODULE_NOT_FOUND";
23
+ return error;
24
+ };
17
25
 
18
26
  const ACCOUNT_CONFIG_ID = "cfg-1";
19
27
  const ANCHOR_VECTOR = [1, 0, 0, 0];
@@ -148,12 +156,64 @@ const trackingMoveService = () => {
148
156
  };
149
157
  };
150
158
 
159
+ /**
160
+ * Deps whose semantic side is the in-memory vector store — the vector-backed
161
+ * deployment. `listAccountFilterMessages` returns the given corpus (empty by
162
+ * default), and constructing the semantic side is tracked so a literal-only
163
+ * predicate can be shown never to build it.
164
+ */
151
165
  const matchDeps = (
152
166
  store: ReturnType<typeof createMemoryVectorStore>,
153
- ): OrganizeMatchDeps => ({
154
- buildAnchor: async () => anchorPayload,
155
- vectorStore: store,
156
- listAccountMessageIds: async () => [],
167
+ corpus: OrganizeCandidate[] = [],
168
+ ): OrganizeMatchDeps & { semanticBuilds: () => number } => {
169
+ let semanticBuilds = 0;
170
+ return {
171
+ semantic: () => {
172
+ semanticBuilds += 1;
173
+ return { buildAnchor: async () => anchorPayload, vectorStore: store };
174
+ },
175
+ listAccountFilterMessages: async () => corpus,
176
+ semanticBuilds: () => semanticBuilds,
177
+ };
178
+ };
179
+
180
+ /**
181
+ * Deps modelling a deployment that ships no vector pipeline: any attempt to
182
+ * build or use the semantic side raises the missing-module shape, and the
183
+ * literal corpus is served from plain rows.
184
+ */
185
+ const vectorlessDeps = (
186
+ corpus: OrganizeCandidate[],
187
+ ): OrganizeMatchDeps & { semanticUsed: () => boolean } => {
188
+ let semanticUsed = false;
189
+ return {
190
+ semantic: () => {
191
+ semanticUsed = true;
192
+ return {
193
+ buildAnchor: async () => {
194
+ throw moduleNotFound();
195
+ },
196
+ vectorStore: {
197
+ query: async () => {
198
+ throw moduleNotFound();
199
+ },
200
+ getByMessage: async () => {
201
+ throw moduleNotFound();
202
+ },
203
+ },
204
+ };
205
+ },
206
+ listAccountFilterMessages: async () => corpus,
207
+ semanticUsed: () => semanticUsed,
208
+ };
209
+ };
210
+
211
+ const candidate = (
212
+ messageId: string,
213
+ over: Partial<OrganizeCandidate["message"]> = {},
214
+ ): OrganizeCandidate => ({
215
+ messageId,
216
+ message: { from: "", fromName: "", subject: "", text: "", ...over },
157
217
  });
158
218
 
159
219
  describe("matchOrganize", () => {
@@ -165,25 +225,30 @@ describe("matchOrganize", () => {
165
225
  bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
166
226
  ]);
167
227
 
168
- const matched = await matchOrganize(
228
+ const { messageIds, semanticUnavailable } = await matchOrganize(
169
229
  matchDeps(store),
170
230
  ACCOUNT_CONFIG_ID,
171
231
  predicate({ actionLabelId: "lbl-1" }),
172
232
  );
173
233
 
174
- assert.deepEqual([...matched].sort(), matching);
234
+ assert.deepEqual([...messageIds].sort(), matching);
235
+ assert.equal(semanticUnavailable, false);
175
236
  });
176
237
 
177
238
  it("matches nothing when the predicate has neither an anchor nor a clause", async () => {
178
239
  const store = createMemoryVectorStore();
179
240
  await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
180
241
 
181
- const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
182
- ...predicate(),
183
- anchorMessageId: "None",
184
- });
242
+ const { messageIds } = await matchOrganize(
243
+ matchDeps(store),
244
+ ACCOUNT_CONFIG_ID,
245
+ {
246
+ ...predicate(),
247
+ anchorMessageId: "None",
248
+ },
249
+ );
185
250
 
186
- assert.deepEqual(matched, []);
251
+ assert.deepEqual(messageIds, []);
187
252
  });
188
253
 
189
254
  it("refines the semantic set by literal clauses", async () => {
@@ -199,12 +264,146 @@ describe("matchOrganize", () => {
199
264
  }),
200
265
  ]);
201
266
 
202
- const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
267
+ const { messageIds } = await matchOrganize(
268
+ matchDeps(store),
269
+ ACCOUNT_CONFIG_ID,
270
+ {
271
+ ...predicate(),
272
+ literalClauses: [{ field: "Subject", value: "reservation" }],
273
+ },
274
+ );
275
+
276
+ assert.deepEqual(messageIds, ["msg-1"]);
277
+ });
278
+
279
+ it("never builds the semantic side for a purely-literal predicate", async () => {
280
+ const store = createMemoryVectorStore();
281
+ const deps = matchDeps(store, [
282
+ candidate("msg-1", { subject: "Dinner reservation" }),
283
+ candidate("msg-2", { subject: "Newsletter" }),
284
+ ]);
285
+
286
+ const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
203
287
  ...predicate(),
288
+ anchorMessageId: "None",
204
289
  literalClauses: [{ field: "Subject", value: "reservation" }],
205
290
  });
206
291
 
207
- assert.deepEqual(matched, ["msg-1"]);
292
+ assert.deepEqual(messageIds, ["msg-1"]);
293
+ assert.equal(
294
+ deps.semanticBuilds(),
295
+ 0,
296
+ "a literal-only predicate must not construct the vector-backed side",
297
+ );
298
+ });
299
+ });
300
+
301
+ describe("matchOrganize on a deployment without the vector pipeline", () => {
302
+ const ORIGINAL = process.env.DATA_BACKEND;
303
+ beforeEach(() => {
304
+ process.env.DATA_BACKEND = "sqlite";
305
+ _resetSemanticCapabilityForTest();
306
+ });
307
+ afterEach(() => {
308
+ if (ORIGINAL === undefined) delete process.env.DATA_BACKEND;
309
+ else process.env.DATA_BACKEND = ORIGINAL;
310
+ _resetSemanticCapabilityForTest();
311
+ });
312
+
313
+ it("matches a literal predicate from the corpus without touching the vector store", async () => {
314
+ const deps = vectorlessDeps([
315
+ candidate("msg-1", { subject: "Dinner reservation" }),
316
+ candidate("msg-2", { subject: "Weekly newsletter" }),
317
+ candidate("msg-3", { subject: "Table reservation confirmed" }),
318
+ ]);
319
+
320
+ const { messageIds, semanticUnavailable } = await matchOrganize(
321
+ deps,
322
+ ACCOUNT_CONFIG_ID,
323
+ {
324
+ ...predicate(),
325
+ anchorMessageId: "None",
326
+ literalClauses: [{ field: "Subject", value: "reservation" }],
327
+ },
328
+ );
329
+
330
+ assert.deepEqual(messageIds, ["msg-1", "msg-3"]);
331
+ assert.equal(semanticUnavailable, false);
332
+ assert.equal(
333
+ deps.semanticUsed(),
334
+ false,
335
+ "a literal-only predicate must never reach the vector store",
336
+ );
337
+ });
338
+
339
+ it("fails loud on a body-content (HasWords) clause rather than matching it against a preview", async () => {
340
+ const deps = vectorlessDeps([
341
+ candidate("msg-1", { subject: "Dinner reservation" }),
342
+ ]);
343
+
344
+ await assert.rejects(
345
+ () =>
346
+ matchOrganize(deps, ACCOUNT_CONFIG_ID, {
347
+ ...predicate(),
348
+ anchorMessageId: "None",
349
+ literalClauses: [{ field: "HasWords", value: "invoice" }],
350
+ }),
351
+ /HasWords/,
352
+ "the vector-free literal path must not silently narrow a body match to a preview",
353
+ );
354
+ assert.equal(deps.semanticUsed(), false);
355
+ });
356
+
357
+ it("degrades an anchor+clauses widen to the literal matches, flagged, instead of crashing", async () => {
358
+ const deps = vectorlessDeps([
359
+ candidate("msg-1", { subject: "Dinner reservation" }),
360
+ candidate("msg-2", { subject: "Weekly newsletter" }),
361
+ ]);
362
+
363
+ const { messageIds, semanticUnavailable } = await matchOrganize(
364
+ deps,
365
+ ACCOUNT_CONFIG_ID,
366
+ {
367
+ ...predicate(),
368
+ literalClauses: [{ field: "Subject", value: "reservation" }],
369
+ },
370
+ );
371
+
372
+ assert.deepEqual(messageIds, ["msg-1"]);
373
+ assert.equal(semanticUnavailable, true);
374
+ });
375
+
376
+ it("degrades an anchor-only widen to an empty flagged result instead of crashing", async () => {
377
+ const deps = vectorlessDeps([candidate("msg-1"), candidate("msg-2")]);
378
+
379
+ const { messageIds, semanticUnavailable } = await matchOrganize(
380
+ deps,
381
+ ACCOUNT_CONFIG_ID,
382
+ predicate(),
383
+ );
384
+
385
+ assert.deepEqual(messageIds, []);
386
+ assert.equal(semanticUnavailable, true);
387
+ });
388
+
389
+ it("propagates a genuine (non-capability) semantic failure loudly", async () => {
390
+ const deps: OrganizeMatchDeps = {
391
+ semantic: () => ({
392
+ buildAnchor: async () => {
393
+ throw new Error("SQLITE_BUSY");
394
+ },
395
+ vectorStore: {
396
+ query: async () => [],
397
+ getByMessage: async () => [],
398
+ },
399
+ }),
400
+ listAccountFilterMessages: async () => [],
401
+ };
402
+
403
+ await assert.rejects(
404
+ () => matchOrganize(deps, ACCOUNT_CONFIG_ID, predicate()),
405
+ /SQLITE_BUSY/,
406
+ );
208
407
  });
209
408
  });
210
409
 
@@ -233,7 +432,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
233
432
  const result = await applyOrganize(
234
433
  { client: tracked.client },
235
434
  ACCOUNT_CONFIG_ID,
236
- applied,
435
+ applied.messageIds,
237
436
  p,
238
437
  );
239
438
 
@@ -260,7 +459,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
260
459
  await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
261
460
  const p = predicate({ actionMailboxId: "mbox-target" });
262
461
 
263
- const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
462
+ const { messageIds: matched } = await matchOrganize(
463
+ matchDeps(store),
464
+ ACCOUNT_CONFIG_ID,
465
+ p,
466
+ );
264
467
  const tracked = trackingClient();
265
468
  const result = await applyOrganize(
266
469
  { client: tracked.client },
@@ -282,7 +485,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
282
485
  ]);
283
486
  const p = predicate({ actionMailboxId: "mbox-target" });
284
487
 
285
- const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
488
+ const { messageIds: matched } = await matchOrganize(
489
+ matchDeps(store),
490
+ ACCOUNT_CONFIG_ID,
491
+ p,
492
+ );
286
493
  const tracked = trackingClient();
287
494
  const mover = trackingMoveService();
288
495
  const result = await applyOrganize(
@@ -324,7 +531,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
324
531
  actionMailboxId: "mbox-target",
325
532
  });
326
533
 
327
- const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
534
+ const { messageIds: matched } = await matchOrganize(
535
+ matchDeps(store),
536
+ ACCOUNT_CONFIG_ID,
537
+ p,
538
+ );
328
539
  const tracked = trackingClient();
329
540
  const mover = trackingMoveService();
330
541
  const result = await applyOrganize(
@@ -351,7 +562,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
351
562
  await store.upsert(matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)));
352
563
  const p = predicate({ actionMailboxId: "mbox-target" });
353
564
 
354
- const matched = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, p);
565
+ const { messageIds: matched } = await matchOrganize(
566
+ matchDeps(store),
567
+ ACCOUNT_CONFIG_ID,
568
+ p,
569
+ );
355
570
  const tracked = trackingClient();
356
571
  const mover = trackingMoveService();
357
572
 
@@ -1,4 +1,5 @@
1
1
  import type { FilterItem, OrganizeJobRequestItem } from "@remit/data-ports";
2
+ import { FilterClauseField } from "@remit/domain-enums";
2
3
  import {
3
4
  DEFAULT_SEMANTIC_MATCH_THRESHOLD,
4
5
  type FilterMessage,
@@ -17,6 +18,7 @@ import {
17
18
  buildVectorStoreFromEnv,
18
19
  } from "@remit/search-service/from-env";
19
20
  import type { RemitClient } from "./dynamodb.js";
21
+ import { noteSemanticCapabilityAbsence } from "./semantic-capability.js";
20
22
 
21
23
  /**
22
24
  * Hard cap on both the previewed and the applied set. A back-apply is a
@@ -63,16 +65,54 @@ export const predicateFromJob = (
63
65
  actionMailboxId: job.actionMailboxId,
64
66
  });
65
67
 
66
- export interface OrganizeMatchDeps {
68
+ /**
69
+ * The vector-backed semantic side of the matcher — the only part that reaches
70
+ * `sqlite-vec`/`@huggingface/transformers`. Constructed lazily (see
71
+ * {@link OrganizeMatchDeps.semantic}) so a literal-only predicate never touches
72
+ * the vector extension, and so a deployment that ships no vector pipeline only
73
+ * fails on the import when a predicate actually asks to widen — where the
74
+ * failure is caught and degraded rather than surfaced as a 500 (#226/#201).
75
+ */
76
+ export interface OrganizeSemanticDeps {
67
77
  buildAnchor: (
68
78
  accountConfigId: string,
69
79
  anchorMessageId: string,
70
80
  ) => Promise<AnchorPayload | null>;
71
81
  vectorStore: Pick<VectorStoreService, "query" | "getByMessage">;
72
- listAccountMessageIds: (
82
+ }
83
+
84
+ /**
85
+ * One corpus message projected onto the fields a literal clause matches
86
+ * against, paired with its id. Sourced from the core message rows, never the
87
+ * vector store, so literal matching runs on any deployment.
88
+ */
89
+ export interface OrganizeCandidate {
90
+ messageId: string;
91
+ message: FilterMessage;
92
+ }
93
+
94
+ export interface OrganizeMatchDeps {
95
+ /**
96
+ * Build the semantic side on demand. Invoked only for an anchored predicate,
97
+ * so constructing the embedder and vector store — and, on first use, the
98
+ * lazy import of the vector extension — is deferred until a widen is actually
99
+ * requested.
100
+ */
101
+ semantic: () => OrganizeSemanticDeps;
102
+ /**
103
+ * The account's messages as literal-match candidates, bounded and vector-free
104
+ * — the corpus slice a purely-literal (or degraded) pass scans.
105
+ */
106
+ listAccountFilterMessages: (
73
107
  accountConfigId: string,
74
108
  limit: number,
75
- ) => Promise<string[]>;
109
+ ) => Promise<OrganizeCandidate[]>;
110
+ }
111
+
112
+ /** The matched ids plus whether the semantic widen was skipped as unavailable. */
113
+ export interface OrganizeMatchResult {
114
+ messageIds: string[];
115
+ semanticUnavailable: boolean;
76
116
  }
77
117
 
78
118
  const hasAnchor = (predicate: OrganizePredicate): boolean =>
@@ -110,62 +150,50 @@ const filterMessageFromChunks = (
110
150
  };
111
151
 
112
152
  /**
113
- * The matcher shared by preview and apply — the previewed set equals the applied
114
- * set for the same input. Read-only. Returns the matching message ids, bounded.
115
- *
116
- * - Semantic anchor: the anchor vector is pooled transiently from the anchor
117
- * message's existing chunk vectors (never a FilterAnchor row), then a k-NN
118
- * query fans out to candidate messages gated on the cosine threshold.
119
- * - Literal clauses (RFC 031) refine the candidate set; a purely-literal
120
- * back-apply scans a bounded slice of the corpus instead.
121
- * - A predicate with neither an anchor nor a clause matches nothing, mirroring
122
- * the index-time filter matcher.
153
+ * The semantic (anchor) arm: pool the anchor vector from the anchor message's
154
+ * existing chunk vectors, fan out with a k-NN query gated on the cosine
155
+ * threshold, then refine by literal clauses reconstructed from the same chunk
156
+ * vectors. Every read here goes through the vector store; a deployment without
157
+ * the vector pipeline fails on the first call, which {@link matchOrganize}
158
+ * catches. Returns null when the anchor message has no vectors to pool.
123
159
  */
124
- export const matchOrganize = async (
125
- deps: OrganizeMatchDeps,
160
+ const matchSemantic = async (
161
+ semantic: OrganizeSemanticDeps,
126
162
  accountConfigId: string,
127
163
  predicate: OrganizePredicate,
128
- limit: number = ORGANIZE_MATCH_LIMIT,
129
- ): Promise<string[]> => {
130
- const anchored = hasAnchor(predicate);
131
- const clauses = predicate.literalClauses;
132
- if (!anchored && clauses.length === 0) return [];
133
-
134
- let base: string[];
135
- if (anchored) {
136
- const anchor = await deps.buildAnchor(
137
- accountConfigId,
138
- predicate.anchorMessageId,
139
- );
140
- if (!anchor) return [];
141
- const threshold =
142
- predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
143
- const matches = await deps.vectorStore.query({
144
- vector: anchor.anchorEmbedding,
145
- topK: limit * VECTOR_CHUNK_FACTOR,
146
- filter: { accountConfigId },
147
- });
148
- const bestScore = new Map<string, number>();
149
- for (const match of matches) {
150
- const messageId = match.metadata.messageId;
151
- const prev = bestScore.get(messageId);
152
- if (prev === undefined || match.score > prev) {
153
- bestScore.set(messageId, match.score);
154
- }
164
+ limit: number,
165
+ ): Promise<string[] | null> => {
166
+ const anchor = await semantic.buildAnchor(
167
+ accountConfigId,
168
+ predicate.anchorMessageId,
169
+ );
170
+ if (!anchor) return null;
171
+ const threshold =
172
+ predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
173
+ const matches = await semantic.vectorStore.query({
174
+ vector: anchor.anchorEmbedding,
175
+ topK: limit * VECTOR_CHUNK_FACTOR,
176
+ filter: { accountConfigId },
177
+ });
178
+ const bestScore = new Map<string, number>();
179
+ for (const match of matches) {
180
+ const messageId = match.metadata.messageId;
181
+ const prev = bestScore.get(messageId);
182
+ if (prev === undefined || match.score > prev) {
183
+ bestScore.set(messageId, match.score);
155
184
  }
156
- base = [...bestScore.entries()]
157
- .filter(([, score]) => score >= threshold)
158
- .map(([messageId]) => messageId);
159
- } else {
160
- base = await deps.listAccountMessageIds(accountConfigId, limit);
161
185
  }
186
+ const base = [...bestScore.entries()]
187
+ .filter(([, score]) => score >= threshold)
188
+ .map(([messageId]) => messageId);
162
189
 
190
+ const clauses = predicate.literalClauses;
163
191
  if (clauses.length === 0) return base.slice(0, limit);
164
192
 
165
193
  const matched: string[] = [];
166
194
  for (const messageId of base) {
167
195
  if (matched.length >= limit) break;
168
- const records = await deps.vectorStore.getByMessage(messageId);
196
+ const records = await semantic.vectorStore.getByMessage(messageId);
169
197
  const message = filterMessageFromChunks(records);
170
198
  if (!message) continue;
171
199
  if (literalClausesMatch(clauses, predicate.matchOperator, message)) {
@@ -175,75 +203,205 @@ export const matchOrganize = async (
175
203
  return matched;
176
204
  };
177
205
 
178
- let cachedVectorStore: Pick<
179
- VectorStoreService,
180
- "query" | "getByMessage"
181
- > | null = null;
206
+ /**
207
+ * A `HasWords` clause matches the message body (RFC 031, `clauseMatches`). The
208
+ * live index-time filter evaluates it against the full parsed body
209
+ * (`body-sync.ts` `toFilterMessage`); the vector-free corpus slice carries only
210
+ * `From`/`Subject` at full fidelity and no faithful body, so it cannot serve a
211
+ * body-content clause without silently narrowing the match to whatever preview
212
+ * happened to be indexed. Body-content matching therefore requires the widen
213
+ * (vector) path — {@link matchSemantic} reconstructs body text from chunk
214
+ * previews there. This guard keeps the two matchers from diverging silently: a
215
+ * body-content clause reaching the vector-free path fails loud instead of
216
+ * returning a wrong set. It is unreachable today — no product surface emits a
217
+ * `HasWords` clause (the organize UI sends empty `literalClauses`, the filter
218
+ * builder emits none) — and stays a fail-fast for any future surface that does.
219
+ */
220
+ const assertNoBodyContentClause = (
221
+ clauses: OrganizePredicate["literalClauses"],
222
+ ): void => {
223
+ if (clauses.some((clause) => clause.field === FilterClauseField.HasWords)) {
224
+ throw new Error(
225
+ "Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path",
226
+ );
227
+ }
228
+ };
182
229
 
183
- const getVectorStore = (): Pick<
184
- VectorStoreService,
185
- "query" | "getByMessage"
186
- > => {
187
- if (!cachedVectorStore) {
188
- const embedder = buildEmbeddingServiceFromEnv();
189
- cachedVectorStore = buildVectorStoreFromEnv(embedder.dimensions);
230
+ /**
231
+ * The literal-only arm: scan a bounded, vector-free slice of the corpus and keep
232
+ * the messages whose literal clauses match. Used both for a purely-literal
233
+ * predicate and as the degraded fallback when a widen is requested on a
234
+ * deployment without the vector pipeline. Serves `From`/`Subject` clauses at
235
+ * full fidelity from the core thread rows; body-content (`HasWords`) clauses are
236
+ * rejected up front (see {@link assertNoBodyContentClause}).
237
+ */
238
+ const matchLiteral = async (
239
+ deps: OrganizeMatchDeps,
240
+ accountConfigId: string,
241
+ predicate: OrganizePredicate,
242
+ limit: number,
243
+ ): Promise<string[]> => {
244
+ const clauses = predicate.literalClauses;
245
+ assertNoBodyContentClause(clauses);
246
+ const candidates = await deps.listAccountFilterMessages(
247
+ accountConfigId,
248
+ limit,
249
+ );
250
+ if (clauses.length === 0) {
251
+ return candidates.slice(0, limit).map((c) => c.messageId);
252
+ }
253
+ const matched: string[] = [];
254
+ for (const { messageId, message } of candidates) {
255
+ if (matched.length >= limit) break;
256
+ if (literalClausesMatch(clauses, predicate.matchOperator, message)) {
257
+ matched.push(messageId);
258
+ }
259
+ }
260
+ return matched;
261
+ };
262
+
263
+ /**
264
+ * The matcher shared by preview and apply — the previewed set equals the applied
265
+ * set for the same input. Read-only. Returns the matching message ids, bounded,
266
+ * and whether the semantic widen was skipped as unavailable.
267
+ *
268
+ * - Semantic anchor: {@link matchSemantic}, built lazily so a literal-only
269
+ * predicate never touches the vector extension.
270
+ * - Literal clauses (RFC 031) refine the candidate set; a purely-literal
271
+ * back-apply scans a bounded vector-free slice of the corpus instead.
272
+ * - A predicate with neither an anchor nor a clause matches nothing, mirroring
273
+ * the index-time filter matcher.
274
+ * - When a widen is requested but this deployment ships no vector pipeline, the
275
+ * capability-absence is absorbed exactly as `/search/semantic` absorbs it:
276
+ * the literal matches are returned (empty for an anchor-only predicate) with
277
+ * `semanticUnavailable` set, never a 500. Any other failure propagates.
278
+ */
279
+ export const matchOrganize = async (
280
+ deps: OrganizeMatchDeps,
281
+ accountConfigId: string,
282
+ predicate: OrganizePredicate,
283
+ limit: number = ORGANIZE_MATCH_LIMIT,
284
+ ): Promise<OrganizeMatchResult> => {
285
+ const anchored = hasAnchor(predicate);
286
+ const clauses = predicate.literalClauses;
287
+ if (!anchored && clauses.length === 0) {
288
+ return { messageIds: [], semanticUnavailable: false };
289
+ }
290
+
291
+ if (!anchored) {
292
+ const messageIds = await matchLiteral(
293
+ deps,
294
+ accountConfigId,
295
+ predicate,
296
+ limit,
297
+ );
298
+ return { messageIds, semanticUnavailable: false };
299
+ }
300
+
301
+ try {
302
+ const semanticIds = await matchSemantic(
303
+ deps.semantic(),
304
+ accountConfigId,
305
+ predicate,
306
+ limit,
307
+ );
308
+ return { messageIds: semanticIds ?? [], semanticUnavailable: false };
309
+ } catch (error) {
310
+ if (!noteSemanticCapabilityAbsence(error)) throw error;
311
+ // The widen cannot run on this deployment. Fall back to the literal
312
+ // clauses over the corpus — nothing when the predicate is anchor-only —
313
+ // and flag the absence so the client can say so.
314
+ if (clauses.length === 0) {
315
+ return { messageIds: [], semanticUnavailable: true };
316
+ }
317
+ const messageIds = await matchLiteral(
318
+ deps,
319
+ accountConfigId,
320
+ predicate,
321
+ limit,
322
+ );
323
+ return { messageIds, semanticUnavailable: true };
190
324
  }
191
- return cachedVectorStore;
192
325
  };
193
326
 
194
- const buildAnchorFromEnv = (): OrganizeMatchDeps["buildAnchor"] => {
327
+ let cachedSemantic: OrganizeSemanticDeps | null = null;
328
+
329
+ /**
330
+ * Construct the semantic side from env on first use and memoize it. The embedder
331
+ * and vector store are selected by env, independent of `DATA_BACKEND`; building
332
+ * them is cheap (the vector extension imports lazily on the first store call),
333
+ * but deferring construction keeps a literal-only preview clear of the whole
334
+ * semantic graph.
335
+ */
336
+ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
337
+ if (cachedSemantic) return cachedSemantic;
195
338
  const embedder = buildEmbeddingServiceFromEnv();
196
339
  const store = buildVectorStoreFromEnv(embedder.dimensions);
197
- return (accountConfigId, anchorMessageId) =>
198
- buildMessageAnchor(
199
- { store, embedder },
200
- { accountConfigId, anchorMessageId },
201
- );
340
+ cachedSemantic = {
341
+ buildAnchor: (accountConfigId, anchorMessageId) =>
342
+ buildMessageAnchor(
343
+ { store, embedder },
344
+ { accountConfigId, anchorMessageId },
345
+ ),
346
+ vectorStore: store,
347
+ };
348
+ return cachedSemantic;
202
349
  };
203
350
 
204
351
  /**
205
- * A bounded corpus slice for a purely-literal back-apply: the account's message
206
- * ids, gathered mailbox by mailbox and capped. Paginated per mailbox so a large
207
- * corpus never loads at once.
352
+ * A bounded, vector-free corpus slice for a literal back-apply: the account's
353
+ * messages projected onto the literal-match fields, gathered newest-first across
354
+ * every mailbox and capped. Reads the core thread rows, so it runs on a
355
+ * deployment that ships no vector pipeline. Deduped by message id — the same
356
+ * mail filed in two folders is one candidate.
357
+ *
358
+ * `From`/`Subject` come from the row verbatim (full fidelity). `text` (the body)
359
+ * is left empty: the thread row carries only a truncated `snippet`, and matching
360
+ * a body-content clause against a preview would silently diverge from the live
361
+ * index-time filter's full-body match. Body-content (`HasWords`) clauses are
362
+ * rejected before this is read (see {@link assertNoBodyContentClause}), so the
363
+ * empty `text` is never matched against.
208
364
  */
209
- const listAccountMessageIdsFromClient =
210
- (client: RemitClient): OrganizeMatchDeps["listAccountMessageIds"] =>
365
+ const listAccountFilterMessagesFromClient =
366
+ (client: RemitClient): OrganizeMatchDeps["listAccountFilterMessages"] =>
211
367
  async (accountConfigId, limit) => {
212
- const accounts =
213
- await client.account.listAllByAccountConfig(accountConfigId);
214
- const ids: string[] = [];
215
- for (const account of accounts) {
216
- const mailboxes = await client.mailbox.listAllByAccount(
217
- account.accountId,
218
- );
219
- for (const mailbox of mailboxes) {
220
- let continuationToken: string | undefined;
221
- do {
222
- const page = await client.message.listByMailbox(mailbox.mailboxId, {
223
- limit,
224
- continuationToken,
225
- });
226
- for (const message of page.items) {
227
- ids.push(message.messageId);
228
- if (ids.length >= limit) return ids;
229
- }
230
- continuationToken = page.continuationToken;
231
- } while (continuationToken);
368
+ const candidates: OrganizeCandidate[] = [];
369
+ const seen = new Set<string>();
370
+ let continuationToken: string | undefined;
371
+ do {
372
+ const page = await client.threadMessage.listByDate(accountConfigId, {
373
+ limit,
374
+ continuationToken,
375
+ excludeDeleted: true,
376
+ });
377
+ for (const row of page.items) {
378
+ if (seen.has(row.messageId)) continue;
379
+ seen.add(row.messageId);
380
+ candidates.push({
381
+ messageId: row.messageId,
382
+ message: {
383
+ from: row.fromEmail ?? "",
384
+ fromName: row.fromName ?? "",
385
+ subject: row.subject ?? "",
386
+ text: "",
387
+ },
388
+ });
389
+ if (candidates.length >= limit) return candidates;
232
390
  }
233
- }
234
- return ids;
391
+ continuationToken = page.continuationToken;
392
+ } while (continuationToken);
393
+ return candidates;
235
394
  };
236
395
 
237
396
  /**
238
- * The env-wired matcher deps for the running backend/worker. The vector store
239
- * and embedder are selected by env, independent of `DATA_BACKEND`.
397
+ * The env-wired matcher deps for the running backend/worker. The semantic side
398
+ * is built lazily so a literal-only preview never constructs it.
240
399
  */
241
400
  export const buildOrganizeMatchDeps = (
242
401
  client: RemitClient,
243
402
  ): OrganizeMatchDeps => ({
244
- buildAnchor: buildAnchorFromEnv(),
245
- vectorStore: getVectorStore(),
246
- listAccountMessageIds: listAccountMessageIdsFromClient(client),
403
+ semantic: buildSemanticFromEnv,
404
+ listAccountFilterMessages: listAccountFilterMessagesFromClient(client),
247
405
  });
248
406
 
249
407
  /**