@remit/account-worker 0.0.25 → 0.0.26

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/account-worker",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -1,10 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { afterEach, describe, it } from "node:test";
3
5
  import type { RemitClient } from "@remit/backend/client";
4
- import type { OrganizeMatchDeps } from "@remit/backend/organize";
6
+ import {
7
+ buildOrganizeMatchDeps,
8
+ type OrganizeMatchDeps,
9
+ type OrganizeSemanticDeps,
10
+ } from "@remit/backend/organize";
11
+ import type { FilterAnchorItem, FilterItem } from "@remit/data-ports";
12
+ import { NotFoundError } from "@remit/data-ports/errors";
13
+ import { DrizzleThreadMessageRepository } from "@remit/drizzle-service";
14
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
5
15
  import { noopLogger } from "@remit/logger-lambda/noop-logger";
6
16
  import type { OrganizeJobEvent } from "../events.js";
7
- import { processOrganizeJob } from "./organize-job.js";
17
+ import {
18
+ type ProcessOrganizeJobDeps,
19
+ processOrganizeJob,
20
+ } from "./organize-job.js";
8
21
 
9
22
  interface Update {
10
23
  state: string;
@@ -29,6 +42,7 @@ const jobClient = (
29
42
  get: async () => ({
30
43
  organizeJobId: "job-1",
31
44
  accountConfigId: "cfg-1",
45
+ filterId: "None",
32
46
  anchorMessageId: "None",
33
47
  matchOperator: "And",
34
48
  literalClauses,
@@ -56,6 +70,7 @@ const explodingMatchDeps = (): OrganizeMatchDeps =>
56
70
  throw new Error("SQLITE_BUSY");
57
71
  },
58
72
  filterAnchors: {
73
+ get: async () => null,
59
74
  listByAccountConfig: async () => [],
60
75
  put: async () => {
61
76
  throw new Error("unreachable");
@@ -104,3 +119,323 @@ describe("processOrganizeJob (reader #463)", () => {
104
119
  assert.equal(updates[1]?.errorMessage, "SQLITE_BUSY");
105
120
  });
106
121
  });
122
+
123
+ interface Move {
124
+ messageId: string;
125
+ destinationMailboxId: string;
126
+ }
127
+
128
+ const CONFIG = "cfg-1";
129
+ const INBOX = "mbx-inbox";
130
+ const ARCHIVE = "mbx-archive";
131
+
132
+ const standingFilter = (over: Partial<FilterItem> = {}): FilterItem => ({
133
+ filterId: "flt-1",
134
+ accountConfigId: CONFIG,
135
+ name: "Invoices",
136
+ scope: "Standing",
137
+ state: "Active",
138
+ disabledReason: "None",
139
+ hasAnchor: false,
140
+ ruleChangedAt: 1,
141
+ actionChangedAt: 1,
142
+ matchOperator: "And",
143
+ literalClauses: [{ field: "From", value: "billing@example.com" }],
144
+ actionLabelId: "None",
145
+ actionMailboxId: "mbx-invoices",
146
+ createdAt: 0,
147
+ updatedAt: 0,
148
+ ...over,
149
+ });
150
+
151
+ /**
152
+ * The thread rows live in the shipped SQLite schema, read through the real
153
+ * repository, so the inbox narrowing is the store's own query. One invoice
154
+ * sits in the inbox and one in the archive, both from the filter's sender
155
+ * and both saying "overdue" in their preview.
156
+ */
157
+ const seedThreads = async (
158
+ threadMessage: DrizzleThreadMessageRepository,
159
+ ): Promise<{ inbox: string; archive: string }> => {
160
+ const seed = async (mailboxId: string, uid: number): Promise<string> => {
161
+ const messageId = randomUUID();
162
+ await threadMessage.create({
163
+ accountConfigId: CONFIG,
164
+ threadId: randomUUID(),
165
+ messageId,
166
+ mailboxId,
167
+ uid,
168
+ referenceOrder: 0,
169
+ internalDate: uid,
170
+ sentDate: uid,
171
+ subject: "Your invoice",
172
+ fromEmail: "billing@example.com",
173
+ fromName: "Billing",
174
+ snippet: "This invoice is overdue",
175
+ isRead: false,
176
+ isDeleted: false,
177
+ hasAttachment: false,
178
+ hasStars: false,
179
+ });
180
+ return messageId;
181
+ };
182
+ return { inbox: await seed(INBOX, 1), archive: await seed(ARCHIVE, 2) };
183
+ };
184
+
185
+ interface World {
186
+ client: RemitClient;
187
+ inbox: string;
188
+ archive: string;
189
+ updates: Update[];
190
+ moves: Move[];
191
+ labeled: string[];
192
+ close: () => void;
193
+ }
194
+
195
+ const world = async (
196
+ filters: FilterItem[],
197
+ anchors: FilterAnchorItem[] = [],
198
+ ): Promise<World> => {
199
+ const store = createShippedSqliteDb();
200
+ store.sqlite.exec(
201
+ readFileSync(
202
+ new URL(
203
+ "../../../../npm-scripts/sqlite-search-index.sql",
204
+ import.meta.url,
205
+ ),
206
+ "utf8",
207
+ ),
208
+ );
209
+ const threadMessage = new DrizzleThreadMessageRepository(store.db as never);
210
+ const { inbox, archive } = await seedThreads(threadMessage);
211
+ const updates: Update[] = [];
212
+ const labeled: string[] = [];
213
+ const mailboxOf: Record<string, string> = {
214
+ [inbox]: INBOX,
215
+ [archive]: ARCHIVE,
216
+ };
217
+ const client = {
218
+ threadMessage,
219
+ organizeJobRequest: {
220
+ get: async () => ({
221
+ organizeJobId: "job-1",
222
+ accountConfigId: CONFIG,
223
+ filterId: "flt-1",
224
+ anchorMessageId: "None",
225
+ matchOperator: "And",
226
+ literalClauses: [],
227
+ similarityThreshold: 0.75,
228
+ actionLabelId: "None",
229
+ actionMailboxId: "None",
230
+ }),
231
+ update: async (_id: string, patch: Update) => {
232
+ updates.push(patch);
233
+ },
234
+ },
235
+ filter: {
236
+ get: async (_cfg: string, filterId: string) => {
237
+ const found = filters.find((f) => f.filterId === filterId);
238
+ if (!found) throw new NotFoundError(`Filter not found: ${filterId}`);
239
+ return found;
240
+ },
241
+ refreshExpiry: async (filter: FilterItem) => filter,
242
+ listByAccountAndState: async (_cfg: string, state: string) =>
243
+ filters.filter((f) => f.state === state),
244
+ },
245
+ filterAnchor: {
246
+ get: async (_cfg: string, filterId: string) =>
247
+ anchors.find((anchor) => anchor.filterId === filterId) ?? null,
248
+ },
249
+ account: {
250
+ listMailSourcesByAccountConfig: async () => [{ accountId: "acct-1" }],
251
+ },
252
+ mailboxSpecialUse: {
253
+ findInboxMailbox: async () => ({ mailboxId: INBOX, fullPath: "INBOX" }),
254
+ },
255
+ message: {
256
+ get: async (messageId: string) => ({
257
+ messageId,
258
+ mailboxId: mailboxOf[messageId],
259
+ }),
260
+ },
261
+ mailbox: { resolveAccountId: async () => "acct-1" },
262
+ messageLabel: {
263
+ apply: async (input: { messageId: string }) => {
264
+ labeled.push(input.messageId);
265
+ return {};
266
+ },
267
+ },
268
+ } as unknown as RemitClient;
269
+ return {
270
+ client,
271
+ inbox,
272
+ archive,
273
+ updates,
274
+ moves: [],
275
+ labeled,
276
+ close: store.close,
277
+ };
278
+ };
279
+
280
+ const recordingMoves = (moves: Move[]) =>
281
+ ({
282
+ moveMessage: async (
283
+ _cfg: string,
284
+ messageId: string,
285
+ destinationMailboxId: string,
286
+ ) => {
287
+ moves.push({ messageId, destinationMailboxId });
288
+ },
289
+ }) as unknown as NonNullable<ProcessOrganizeJobDeps["moveService"]>;
290
+
291
+ const runFilterJob = (w: World) =>
292
+ processOrganizeJob(event, noopLogger, {
293
+ client: w.client,
294
+ matchDeps: buildOrganizeMatchDeps(w.client),
295
+ moveService: recordingMoves(w.moves),
296
+ });
297
+
298
+ const EMBEDDING_ID = "test-model@4";
299
+ const ANCHOR_VECTOR = [1, 0, 0, 0];
300
+ const ORTHOGONAL_VECTOR = [0, 1, 0, 0];
301
+
302
+ const persistedAnchor: FilterAnchorItem = {
303
+ filterId: "flt-1",
304
+ accountConfigId: CONFIG,
305
+ anchorMessageId: "msg-anchor",
306
+ anchorEmbedding: ANCHOR_VECTOR,
307
+ anchorEmbeddingId: EMBEDDING_ID,
308
+ anchorSourceText: "Your invoice",
309
+ createdAt: 0,
310
+ updatedAt: 0,
311
+ };
312
+
313
+ /**
314
+ * The vector index answers the anchor with the inbox invoice, and only when
315
+ * asked about the inbox: the kNN read is where a similarity filter finds its
316
+ * candidates.
317
+ */
318
+ const vectorMatchDeps = (w: World): OrganizeMatchDeps => ({
319
+ ...buildOrganizeMatchDeps(w.client),
320
+ semantic: () => ({
321
+ buildAnchor: async () => {
322
+ throw new Error("a filter widens on its persisted anchor");
323
+ },
324
+ vectorStore: {
325
+ query: async (query: { filter?: { mailboxId?: string } }) =>
326
+ query.filter?.mailboxId === INBOX
327
+ ? [
328
+ {
329
+ chunkId: `${w.inbox}#body`,
330
+ score: 0.9,
331
+ metadata: { messageId: w.inbox, mailboxIds: [INBOX] },
332
+ },
333
+ ]
334
+ : [],
335
+ getByMessage: async () => [],
336
+ } as unknown as OrganizeSemanticDeps["vectorStore"],
337
+ embed: async () => ANCHOR_VECTOR,
338
+ embeddingId: EMBEDDING_ID,
339
+ }),
340
+ });
341
+
342
+ describe("processOrganizeJob for a filter job (#1354)", () => {
343
+ let current: World | undefined;
344
+ afterEach(() => current?.close());
345
+
346
+ it("moves the pre-existing matching inbox message and leaves the archive alone", async () => {
347
+ current = await world([standingFilter()]);
348
+
349
+ await runFilterJob(current);
350
+
351
+ assert.deepEqual(current.moves, [
352
+ { messageId: current.inbox, destinationMailboxId: "mbx-invoices" },
353
+ ]);
354
+ const done = current.updates.at(-1);
355
+ assert.equal(done?.state, "Complete");
356
+ assert.equal(done?.matchedCount, 1);
357
+ assert.equal(done?.appliedCount, 1);
358
+ });
359
+
360
+ it("matches a free-text rule from search on the stored preview", async () => {
361
+ current = await world([
362
+ standingFilter({
363
+ literalClauses: [{ field: "HasWords", value: "overdue" }],
364
+ }),
365
+ ]);
366
+
367
+ await runFilterJob(current);
368
+
369
+ assert.deepEqual(current.moves, [
370
+ { messageId: current.inbox, destinationMailboxId: "mbx-invoices" },
371
+ ]);
372
+ assert.equal(current.updates.at(-1)?.state, "Complete");
373
+ });
374
+
375
+ it("leaves the move to a newer filter that also matches, and still labels", async () => {
376
+ current = await world([
377
+ standingFilter({ actionLabelId: "lbl-bills" }),
378
+ standingFilter({
379
+ filterId: "flt-2",
380
+ name: "Receipts",
381
+ actionChangedAt: 2,
382
+ actionMailboxId: "mbx-receipts",
383
+ }),
384
+ ]);
385
+
386
+ await runFilterJob(current);
387
+
388
+ assert.deepEqual(current.moves, []);
389
+ assert.deepEqual(current.labeled, [current.inbox]);
390
+ assert.equal(current.updates.at(-1)?.state, "Complete");
391
+ });
392
+
393
+ it("fails the job without a retry when the filter is turned off", async () => {
394
+ current = await world([
395
+ standingFilter({ state: "Disabled", disabledReason: "UserDisabled" }),
396
+ ]);
397
+
398
+ await runFilterJob(current);
399
+
400
+ assert.deepEqual(current.moves, []);
401
+ assert.equal(current.updates.at(-1)?.state, "Failed");
402
+ assert.match(String(current.updates.at(-1)?.errorMessage), /turned off/);
403
+ });
404
+
405
+ it("fails the job without a retry when the filter was deleted", async () => {
406
+ current = await world([]);
407
+
408
+ await runFilterJob(current);
409
+
410
+ assert.equal(current.updates.at(-1)?.state, "Failed");
411
+ assert.match(String(current.updates.at(-1)?.errorMessage), /deleted/);
412
+ });
413
+
414
+ it("moves a message the vector query found, though the preview scores below the threshold", async () => {
415
+ current = await world(
416
+ [standingFilter({ hasAnchor: true, literalClauses: [] })],
417
+ [persistedAnchor],
418
+ );
419
+
420
+ // The index-time evaluator embeds subject and preview; this embedder puts
421
+ // that text orthogonal to the anchor, so evaluate alone would call the
422
+ // message a miss. It only orders the move, so the vector match stands.
423
+ await processOrganizeJob(event, noopLogger, {
424
+ client: current.client,
425
+ matchDeps: vectorMatchDeps(current),
426
+ moveService: recordingMoves(current.moves),
427
+ embedder: {
428
+ embed: async () => ORTHOGONAL_VECTOR,
429
+ embeddingId: EMBEDDING_ID,
430
+ },
431
+ });
432
+
433
+ assert.deepEqual(current.moves, [
434
+ { messageId: current.inbox, destinationMailboxId: "mbx-invoices" },
435
+ ]);
436
+ const done = current.updates.at(-1);
437
+ assert.equal(done?.state, "Complete");
438
+ assert.equal(done?.matchedCount, 1);
439
+ assert.equal(done?.appliedCount, 1);
440
+ });
441
+ });
@@ -1,11 +1,16 @@
1
1
  import { getClient, type RemitClient } from "@remit/backend/client";
2
2
  import {
3
+ type ApplyOrganizeDeps,
3
4
  applyOrganize,
5
+ backApplyFilter,
4
6
  buildOrganizeMatchDeps,
5
7
  buildOrganizeMoveService,
8
+ type FilterBackApplyDeps,
9
+ isFilterJob,
6
10
  matchOrganize,
7
11
  ORGANIZE_MATCH_LIMIT,
8
12
  type OrganizeMatchDeps,
13
+ type OrganizeRejection,
9
14
  predicateFromJob,
10
15
  } from "@remit/backend/organize";
11
16
  import type { Logger } from "@remit/logger-lambda";
@@ -14,12 +19,64 @@ import type { OrganizeJobEvent } from "../events.js";
14
19
  export interface ProcessOrganizeJobDeps {
15
20
  client?: RemitClient;
16
21
  matchDeps?: OrganizeMatchDeps;
22
+ moveService?: ApplyOrganizeDeps["moveService"];
23
+ embedder?: FilterBackApplyDeps["embedder"];
17
24
  }
18
25
 
26
+ interface OrganizeOutcome {
27
+ rejected: OrganizeRejection | null;
28
+ matched: number;
29
+ applied: number;
30
+ failed: number;
31
+ semanticUnavailable: boolean;
32
+ }
33
+
34
+ const rejectedOutcome = (rejected: OrganizeRejection): OrganizeOutcome => ({
35
+ rejected,
36
+ matched: 0,
37
+ applied: 0,
38
+ failed: 0,
39
+ semanticUnavailable: false,
40
+ });
41
+
42
+ /**
43
+ * The "all like these" pass: the predicate snapshotted on the row, over every
44
+ * folder, every requested move applied (reader #497).
45
+ */
46
+ const runSnapshot = async (
47
+ client: RemitClient,
48
+ matchDeps: OrganizeMatchDeps,
49
+ moveService: ApplyOrganizeDeps["moveService"],
50
+ job: Awaited<ReturnType<RemitClient["organizeJobRequest"]["get"]>>,
51
+ ): Promise<OrganizeOutcome> => {
52
+ const predicate = predicateFromJob(job);
53
+ const match = await matchOrganize(
54
+ matchDeps,
55
+ job.accountConfigId,
56
+ predicate,
57
+ ORGANIZE_MATCH_LIMIT,
58
+ );
59
+ if (match.rejected) return rejectedOutcome(match.rejected);
60
+ const { applied, failed } = await applyOrganize(
61
+ { client, moveService },
62
+ job.accountConfigId,
63
+ match.messageIds,
64
+ predicate,
65
+ );
66
+ return {
67
+ rejected: null,
68
+ matched: match.messageIds.length,
69
+ applied,
70
+ failed,
71
+ semanticUnavailable: match.semanticUnavailable,
72
+ };
73
+ };
74
+
19
75
  /**
20
- * Run a "all like these" back-apply job (RFC 034, #1278): match the corpus
21
- * against the job's snapshotted predicate and apply the action to every match,
22
- * in one pass, then record the counts. Mirrors the export job's lifecycle —
76
+ * Run a back-apply job (RFC 034, #1278): an "all like these" pass matches the
77
+ * corpus against the job's snapshotted predicate; a filter job (#1354) runs the
78
+ * named standing filter over the inbox with index-time move precedence. Either
79
+ * applies the action to every match in one pass, then records the counts. Mirrors the export job's lifecycle —
23
80
  * Running, then Complete/Failed — on the same fanout seam.
24
81
  *
25
82
  * Reuses the shared matcher (so the applied set equals what preview returned)
@@ -53,41 +110,37 @@ export const processOrganizeJob = async (
53
110
  );
54
111
 
55
112
  try {
56
- const predicate = predicateFromJob(job);
57
113
  const matchDeps = deps.matchDeps ?? buildOrganizeMatchDeps(client);
58
- const match = await matchOrganize(
59
- matchDeps,
60
- accountConfigId,
61
- predicate,
62
- ORGANIZE_MATCH_LIMIT,
63
- );
114
+ const moveService = deps.moveService ?? buildOrganizeMoveService(client);
115
+ const outcome = isFilterJob(job)
116
+ ? await backApplyFilter(
117
+ { client, matchDeps, moveService, embedder: deps.embedder },
118
+ accountConfigId,
119
+ job.filterId,
120
+ ).then((result) =>
121
+ result.rejected ? rejectedOutcome(result.rejected) : result,
122
+ )
123
+ : await runSnapshot(client, matchDeps, moveService, job);
64
124
 
65
- if (match.rejected) {
125
+ if (outcome.rejected) {
66
126
  await client.organizeJobRequest.update(organizeJobId, {
67
127
  state: "Failed",
68
128
  matchedCount: 0,
69
129
  appliedCount: 0,
70
130
  failedCount: 0,
71
- errorMessage: match.rejected.message,
131
+ errorMessage: outcome.rejected.message,
72
132
  });
73
133
  log.warn(
74
- { accountConfigId, organizeJobId, reason: match.rejected.reason },
134
+ { accountConfigId, organizeJobId, reason: outcome.rejected.reason },
75
135
  "Organize back-apply refused the job's rule; failing the job without a retry",
76
136
  );
77
137
  return;
78
138
  }
79
139
 
80
- const { messageIds, semanticUnavailable } = match;
81
- const { applied, failed } = await applyOrganize(
82
- { client, moveService: buildOrganizeMoveService(client) },
83
- accountConfigId,
84
- messageIds,
85
- predicate,
86
- );
87
-
140
+ const { matched, applied, failed, semanticUnavailable } = outcome;
88
141
  await client.organizeJobRequest.update(organizeJobId, {
89
142
  state: "Complete",
90
- matchedCount: messageIds.length,
143
+ matchedCount: matched,
91
144
  appliedCount: applied,
92
145
  failedCount: failed,
93
146
  });
@@ -95,7 +148,8 @@ export const processOrganizeJob = async (
95
148
  {
96
149
  accountConfigId,
97
150
  organizeJobId,
98
- matched: messageIds.length,
151
+ filterId: job.filterId,
152
+ matched,
99
153
  applied,
100
154
  failed,
101
155
  semanticUnavailable,