@remit/mailbox-service 0.0.111 → 0.0.112

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.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +156 -235
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +5 -4
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,260 +0,0 @@
1
- /**
2
- * A body the classifier had nothing to say about is examined once (issue #331).
3
- *
4
- * `Message.category` is NOT NULL with an `uncategorized` default, so that value
5
- * means two things at once: a message the classifier has not reached, and one it
6
- * reached with nothing to say. Keying "needs classifying" on it made every later
7
- * sync pass re-read the body from storage and rewrite both rows for the same
8
- * answer — a read amplification that scales with sync frequency and never
9
- * converges.
10
- *
11
- * `Message.classificationState` is the fact itself, written in the same
12
- * UpdateItem as the answer. It is persistence-only — no response model carries
13
- * it — and it is what makes the declined cohort addressable later without a
14
- * sync pass guessing at it.
15
- *
16
- * The sender in these tests carries a `flags.category` override of
17
- * `uncategorized` — the Reclassify dialog's own value for "no category" — which
18
- * is how a message ends up stored and `uncategorized` at the same time.
19
- */
20
-
21
- import assert from "node:assert/strict";
22
- import { Readable } from "node:stream";
23
- import { describe, it } from "node:test";
24
- import type {
25
- AddressItem,
26
- IAddressRepository,
27
- IEnvelopeRepository,
28
- IMessageRepository,
29
- IThreadMessageRepository,
30
- MessageItem,
31
- ThreadMessageItem,
32
- UpdateMessageInput,
33
- UpdateThreadMessageInput,
34
- } from "@remit/data-ports";
35
- import { NotFoundError } from "@remit/data-ports/errors";
36
- import { MessageCategory } from "@remit/domain-enums";
37
- import type { StorageService } from "@remit/storage-service";
38
- import { BodySyncService } from "./body-sync.js";
39
- import type { IImapConnection } from "./types.js";
40
-
41
- const LINKEDIN_EML = Buffer.from(
42
- [
43
- "From: LinkedIn <messages-noreply@linkedin.com>",
44
- "To: me@example.com",
45
- "Subject: You have a new invitation",
46
- "List-Unsubscribe: <https://www.linkedin.com/e/unsub>",
47
- "Content-Type: text/plain",
48
- "",
49
- "invitation",
50
- ].join("\r\n"),
51
- );
52
-
53
- interface Harness {
54
- service: BodySyncService;
55
- message: MessageItem;
56
- rows: ThreadMessageItem[];
57
- messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
58
- threadUpdates: UpdateThreadMessageInput[];
59
- retrieved: string[];
60
- }
61
-
62
- const buildHarness = (
63
- message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
64
- overrideCategory?: (typeof MessageCategory)[keyof typeof MessageCategory],
65
- ): Harness => {
66
- const messageUpdates: Array<{
67
- messageId: string;
68
- input: UpdateMessageInput;
69
- }> = [];
70
- const threadUpdates: UpdateThreadMessageInput[] = [];
71
- const retrieved: string[] = [];
72
-
73
- const messageRow = {
74
- uid: 1,
75
- mailboxId: "mb-1",
76
- category: MessageCategory.uncategorized,
77
- ...message,
78
- } as unknown as MessageItem;
79
-
80
- const rows: ThreadMessageItem[] = [
81
- {
82
- threadMessageId: "tm-1",
83
- messageId: message.messageId,
84
- mailboxId: "mb-1",
85
- sentDate: 1,
86
- isRead: false,
87
- isDeleted: false,
88
- hasStars: false,
89
- hasAttachment: false,
90
- category: messageRow.category,
91
- } as unknown as ThreadMessageItem,
92
- ];
93
-
94
- const messageService = {
95
- get: async () => messageRow,
96
- update: async (messageId: string, input: UpdateMessageInput) => {
97
- messageUpdates.push({ messageId, input });
98
- Object.assign(messageRow, input);
99
- },
100
- } as unknown as IMessageRepository;
101
-
102
- const threadMessageService = {
103
- findAllByMessageId: async () => rows,
104
- update: async (
105
- _accountConfigId: string,
106
- threadMessageId: string,
107
- input: UpdateThreadMessageInput,
108
- ) => {
109
- threadUpdates.push(input);
110
- const row = rows.find((r) => r.threadMessageId === threadMessageId);
111
- if (row) Object.assign(row, input);
112
- },
113
- } as unknown as IThreadMessageRepository;
114
-
115
- const storageService = {
116
- retrieve: async (key: string) => {
117
- retrieved.push(key);
118
- return LINKEDIN_EML;
119
- },
120
- storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
121
- storeMessageBodyStream: async () => ({
122
- uri: `s3://bodies/${message.messageId}`,
123
- }),
124
- storeParsedBody: async () => {},
125
- listBodyParts: async () => [],
126
- } as unknown as StorageService;
127
-
128
- const addressService = {
129
- getAddress: async () => {
130
- if (overrideCategory === undefined) {
131
- throw new NotFoundError("Address not found");
132
- }
133
- return {
134
- flags: { category: { value: overrideCategory, setAt: 1_000 } },
135
- } as unknown as AddressItem;
136
- },
137
- incrementInboundCount: async () => {},
138
- } as unknown as IAddressRepository;
139
-
140
- const service = new BodySyncService(
141
- messageService,
142
- storageService,
143
- threadMessageService,
144
- addressService,
145
- { listBodyParts: async () => [] } as unknown as IEnvelopeRepository,
146
- { info: () => {}, error: () => {} },
147
- );
148
-
149
- return {
150
- service,
151
- message: messageRow,
152
- rows,
153
- messageUpdates,
154
- threadUpdates,
155
- retrieved,
156
- };
157
- };
158
-
159
- const imapConnection = () =>
160
- ({
161
- openBox: async () => {},
162
- async *fetchMessageBodies(uids: number[]) {
163
- for (const uid of uids) {
164
- yield { uid, source: Readable.from([LINKEDIN_EML]) };
165
- }
166
- },
167
- }) as unknown as IImapConnection;
168
-
169
- const syncPass = (harness: Harness) =>
170
- harness.service.syncBodies(
171
- ["m-1"],
172
- "acc-1",
173
- "cfg-1",
174
- { fullPath: "INBOX", placement: "inbox" },
175
- async () => imapConnection(),
176
- );
177
-
178
- describe("a body the classifier declined to categorize is examined once", () => {
179
- it("does not read or rewrite the stored body on the pass after the one that stored it", async () => {
180
- const harness = buildHarness(
181
- { messageId: "m-1" },
182
- MessageCategory.uncategorized,
183
- );
184
-
185
- const first = await syncPass(harness);
186
-
187
- assert.deepEqual(first.syncedMessageIds, ["m-1"]);
188
- assert.equal(harness.message.category, MessageCategory.uncategorized);
189
- assert.equal(harness.message.bodyStorageKey, "s3://bodies/m-1");
190
- assert.equal(harness.messageUpdates.length, 1);
191
- // The answer and the record that it was given, in one write.
192
- assert.equal(
193
- harness.messageUpdates[0].input.classificationState,
194
- "Examined",
195
- );
196
-
197
- const second = await syncPass(harness);
198
-
199
- assert.equal(second.skippedCount, 1);
200
- assert.deepEqual(second.failedMessageIds, []);
201
- assert.deepEqual(harness.retrieved, []);
202
- assert.equal(harness.messageUpdates.length, 1);
203
- assert.equal(harness.threadUpdates.length, 1);
204
- });
205
-
206
- it("leaves a message that is already stored and examined alone", async () => {
207
- const harness = buildHarness(
208
- {
209
- messageId: "m-1",
210
- bodyStorageKey: "s3://bodies/m-1",
211
- category: MessageCategory.uncategorized,
212
- classificationState: "Examined",
213
- } as Partial<MessageItem> & Pick<MessageItem, "messageId">,
214
- MessageCategory.uncategorized,
215
- );
216
-
217
- const result = await syncPass(harness);
218
-
219
- assert.equal(result.skippedCount, 1);
220
- assert.deepEqual(harness.retrieved, []);
221
- assert.deepEqual(harness.messageUpdates, []);
222
- assert.deepEqual(harness.threadUpdates, []);
223
- });
224
-
225
- it("leaves mail stored before the classifier ran marked as never examined", async () => {
226
- const harness = buildHarness(
227
- {
228
- messageId: "m-1",
229
- bodyStorageKey: "s3://bodies/m-1",
230
- category: MessageCategory.uncategorized,
231
- classificationState: "NotExamined",
232
- } as Partial<MessageItem> & Pick<MessageItem, "messageId">,
233
- MessageCategory.uncategorized,
234
- );
235
-
236
- const result = await syncPass(harness);
237
-
238
- // A sync pass does not re-derive this cohort — that is the amplification
239
- // #331 removes. It leaves the row saying so, which is what lets a
240
- // deliberate backfill select it later.
241
- assert.equal(result.skippedCount, 1);
242
- assert.deepEqual(harness.retrieved, []);
243
- assert.deepEqual(harness.messageUpdates, []);
244
- assert.equal(harness.message.classificationState, "NotExamined");
245
- });
246
-
247
- it("still fetches and classifies a message whose body has never been stored", async () => {
248
- const harness = buildHarness({ messageId: "m-1" });
249
-
250
- const result = await syncPass(harness);
251
-
252
- assert.deepEqual(result.syncedMessageIds, ["m-1"]);
253
- assert.equal(harness.messageUpdates.length, 1);
254
- assert.equal(
255
- harness.messageUpdates[0].input.category,
256
- MessageCategory.social,
257
- );
258
- assert.equal(harness.rows[0].category, MessageCategory.social);
259
- });
260
- });
@@ -1,176 +0,0 @@
1
- /**
2
- * Issue #302 (RFC 039 Decision 3): `Address.flags.unsubscribed` is
3
- * documented as "auto-mark-read until sender stops" and settable from
4
- * `IntelligencePane.tsx`, but nothing consumed it — every message from an
5
- * unsubscribed sender still arrived unread like any other. These tests drive
6
- * `BodySyncService` end-to-end (read-path body materialization →
7
- * `applyPostStoreSteps`) and assert on the actual `FlagQueueService.markAsRead`
8
- * call, so a regression that drops the flag read — not just a helper in
9
- * isolation — shows up here.
10
- */
11
-
12
- import assert from "node:assert/strict";
13
- import { describe, it } from "node:test";
14
- import type {
15
- AddressItem,
16
- IAddressRepository,
17
- IEnvelopeRepository,
18
- IMessageRepository,
19
- IThreadMessageRepository,
20
- } from "@remit/data-ports";
21
- import type { StorageService } from "@remit/storage-service";
22
- import { BodySyncService } from "./body-sync.js";
23
- import type { FlagQueueService } from "./flag-queue.js";
24
- import type { IImapConnection } from "./types.js";
25
-
26
- const PLAIN_EML = (fromEmail: string) =>
27
- Buffer.from(
28
- [
29
- `From: Sender <${fromEmail}>`,
30
- "To: me@example.com",
31
- "Subject: Hello",
32
- "Content-Type: text/plain",
33
- "",
34
- "body",
35
- ].join("\r\n"),
36
- );
37
-
38
- interface MarkReadCall {
39
- accountConfigId: string;
40
- messageId: string;
41
- accountId: string;
42
- }
43
-
44
- interface Harness {
45
- service: BodySyncService;
46
- markReadCalls: MarkReadCall[];
47
- }
48
-
49
- const buildHarness = (
50
- flags: AddressItem["flags"],
51
- withUnsubscribeConfig = true,
52
- ): Harness => {
53
- const markReadCalls: MarkReadCall[] = [];
54
-
55
- const messageService = {
56
- get: async () => ({
57
- messageId: "m-1",
58
- mailboxId: "mb-inbox",
59
- uid: 1,
60
- }),
61
- update: async () => {},
62
- } as unknown as IMessageRepository;
63
-
64
- const threadMessageService = {
65
- findAllByMessageId: async () => [
66
- {
67
- threadMessageId: "tm-1",
68
- sentDate: 1,
69
- mailboxId: "mb-inbox",
70
- isRead: false,
71
- isDeleted: false,
72
- hasStars: false,
73
- hasAttachment: false,
74
- },
75
- ],
76
- update: async () => {},
77
- } as unknown as IThreadMessageRepository;
78
-
79
- const storageService = {
80
- storeMessageBody: async () => ({ uri: "s3://bodies/m-1" }),
81
- storeParsedBody: async () => {},
82
- listBodyParts: async () => [],
83
- } as unknown as StorageService;
84
-
85
- const addressService = {
86
- getAddress: async () => ({ flags }) as unknown as AddressItem,
87
- incrementInboundCount: async () => {},
88
- } as unknown as IAddressRepository;
89
-
90
- const envelopeService = {
91
- listBodyParts: async () => [],
92
- } as unknown as IEnvelopeRepository;
93
-
94
- const flagQueueService = {
95
- markAsRead: async (
96
- accountConfigId: string,
97
- messageId: string,
98
- accountId: string,
99
- ) => {
100
- markReadCalls.push({ accountConfigId, messageId, accountId });
101
- },
102
- } as unknown as FlagQueueService;
103
-
104
- const service = new BodySyncService(
105
- messageService,
106
- storageService,
107
- threadMessageService,
108
- addressService,
109
- envelopeService,
110
- { info: () => {}, error: () => {} },
111
- undefined,
112
- undefined,
113
- undefined,
114
- withUnsubscribeConfig ? { flagQueueService } : undefined,
115
- );
116
-
117
- return { service, markReadCalls };
118
- };
119
-
120
- const readBody = async (
121
- service: BodySyncService,
122
- fromEmail = "someone@example.com",
123
- ) => {
124
- const connection = {
125
- openBox: async () => {},
126
- fetchMessageBody: async () => PLAIN_EML(fromEmail),
127
- } as unknown as IImapConnection;
128
- return service.fetchAndGetBody(
129
- "m-1",
130
- "acc-1",
131
- "cfg-1",
132
- { fullPath: "INBOX", placement: "inbox" },
133
- async () => connection,
134
- );
135
- };
136
-
137
- describe("Address.flags.unsubscribed drives auto-mark-read (issue #302)", () => {
138
- it("marks a message from an unsubscribed sender as read at sync time", async () => {
139
- const harness = buildHarness({ unsubscribed: { value: true, setAt: 1 } });
140
-
141
- await readBody(harness.service);
142
-
143
- assert.deepEqual(harness.markReadCalls, [
144
- { accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
145
- ]);
146
- });
147
-
148
- it("leaves read state alone for a sender without the flag", async () => {
149
- const harness = buildHarness({});
150
-
151
- await readBody(harness.service);
152
-
153
- assert.deepEqual(harness.markReadCalls, []);
154
- });
155
-
156
- it("leaves read state alone once the flag is unset (no caching of the decision)", async () => {
157
- const harness = buildHarness({
158
- unsubscribed: { value: false, setAt: 1 },
159
- });
160
-
161
- await readBody(harness.service);
162
-
163
- assert.deepEqual(harness.markReadCalls, []);
164
- });
165
-
166
- it("is a no-op when body sync was built without an UnsubscribeConfig", async () => {
167
- const harness = buildHarness(
168
- { unsubscribed: { value: true, setAt: 1 } },
169
- false,
170
- );
171
-
172
- await readBody(harness.service);
173
-
174
- assert.deepEqual(harness.markReadCalls, []);
175
- });
176
- });
@@ -1,55 +0,0 @@
1
- import type {
2
- FilterAnchorItem,
3
- IFilterAnchorRepository,
4
- } from "@remit/data-ports";
5
-
6
- /**
7
- * The half of an embedding service an anchor refresh needs: the text-to-vector
8
- * call and the `<modelId>@<dimensions>` identifier of the model behind it (the
9
- * same scheme `EmbeddingService.embeddingId` derives). Compared against
10
- * `FilterAnchor.anchorEmbeddingId` to detect a model drift a same-dimension
11
- * swap would otherwise pass through silently (RFC 039 Decision 1a, reader
12
- * #295).
13
- */
14
- export interface AnchorEmbedder {
15
- embed(text: string): Promise<number[]>;
16
- readonly embeddingId: string;
17
- }
18
-
19
- export interface AnchorDriftDeps {
20
- anchorRepository: Pick<IFilterAnchorRepository, "put">;
21
- /** Absent on a deployment with no embedder wired; then nothing can drift. */
22
- embedder: AnchorEmbedder | undefined;
23
- }
24
-
25
- /**
26
- * The anchor to score against, re-embedded in place when the embedding model
27
- * has drifted since it was written (RFC 039 Decision 1a). No migration job
28
- * walks these rows proactively, so the refresh is lazy: the first read that
29
- * notices the stamp no longer matches the configured model re-embeds the
30
- * already-persisted `anchorSourceText` and writes it back under the current
31
- * id. A failure here is never a terminal state — the row is left as it was,
32
- * so the next read that reaches this anchor retries.
33
- *
34
- * The single mechanism behind both index-time matching
35
- * ({@link FilterPipeline}) and the back-apply pass's cross-filter precedence
36
- * check, which must agree on what a filter currently matches (reader #399).
37
- */
38
- export const refreshAnchorForEmbedder = async (
39
- deps: AnchorDriftDeps,
40
- anchor: FilterAnchorItem,
41
- ): Promise<FilterAnchorItem> => {
42
- const { embedder } = deps;
43
- if (!embedder || anchor.anchorEmbeddingId === embedder.embeddingId) {
44
- return anchor;
45
- }
46
- const anchorEmbedding = await embedder.embed(anchor.anchorSourceText);
47
- return deps.anchorRepository.put({
48
- accountConfigId: anchor.accountConfigId,
49
- filterId: anchor.filterId,
50
- anchorEmbedding,
51
- anchorEmbeddingId: embedder.embeddingId,
52
- anchorSourceText: anchor.anchorSourceText,
53
- anchorMessageId: anchor.anchorMessageId,
54
- });
55
- };
@@ -1,39 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, describe, it } from "node:test";
3
- import {
4
- _resetMessageEmbedderForTest,
5
- getMessageEmbedder,
6
- } from "./message-embedder.js";
7
-
8
- describe("getMessageEmbedder", () => {
9
- afterEach(() => {
10
- _resetMessageEmbedderForTest();
11
- });
12
-
13
- it("adapts the batch embedding service into a single-text message embedder", async () => {
14
- const vector = await getMessageEmbedder().embed("invoice from stripe");
15
-
16
- assert.ok(Array.isArray(vector));
17
- assert.ok(vector.length > 0);
18
- assert.ok(vector.every((value) => typeof value === "number"));
19
- });
20
-
21
- it("embeds the same text deterministically", async () => {
22
- const embedder = getMessageEmbedder();
23
- const a = await embedder.embed("your monthly receipt");
24
- const b = await embedder.embed("your monthly receipt");
25
-
26
- assert.deepEqual(a, b);
27
- });
28
-
29
- it("memoizes the embedder across calls", () => {
30
- assert.strictEqual(getMessageEmbedder(), getMessageEmbedder());
31
- });
32
-
33
- it("rebuilds after a reset", () => {
34
- const first = getMessageEmbedder();
35
- _resetMessageEmbedderForTest();
36
-
37
- assert.notStrictEqual(getMessageEmbedder(), first);
38
- });
39
- });
@@ -1,33 +0,0 @@
1
- import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
2
- import type { MessageEmbedder } from "./pipeline.js";
3
-
4
- /**
5
- * Adapt the batch {@link EmbeddingService} the rest of the stack composes from
6
- * env into the single-text {@link MessageEmbedder} the filter pipeline needs: one
7
- * incoming message becomes one message-level vector to compare against a filter's
8
- * persisted anchor (RFC 034 Decision 2.1/2.3).
9
- *
10
- * The embedder is selected by the same `SEARCH_EMBEDDING_*` env the search-index
11
- * worker reads, so every filter pass embeds under the identical model the anchors
12
- * were built with. The instance is memoized across warm invocations so a local
13
- * Transformers.js model loads once per container rather than per event.
14
- */
15
- let cached: MessageEmbedder | undefined;
16
-
17
- export const getMessageEmbedder = (): MessageEmbedder => {
18
- if (cached) return cached;
19
- const service = buildEmbeddingServiceFromEnv();
20
- cached = {
21
- embed: async (text: string): Promise<number[]> => {
22
- const [vector] = await service.embed([text]);
23
- return vector;
24
- },
25
- embeddingId: service.embeddingId,
26
- };
27
- return cached;
28
- };
29
-
30
- /** Reset the singleton — test use only. */
31
- export const _resetMessageEmbedderForTest = (): void => {
32
- cached = undefined;
33
- };