@remit/backend 0.0.42 → 0.0.43

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.42",
3
+ "version": "0.0.43",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,8 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type { UpdateFilterInput as UpdateFilterRequestBody } from "@remit/api-openapi-types";
4
- import { FilterScope, FilterState } from "@remit/domain-enums";
2
+ import { describe, it, mock } from "node:test";
3
+ import type {
4
+ CreateFilterInput,
5
+ UpdateFilterInput as UpdateFilterRequestBody,
6
+ } from "@remit/api-openapi-types";
7
+ import type {
8
+ CreateFilterAnchorInput,
9
+ CreateFilterInput as FilterAnchorTxCreateInput,
10
+ FilterItem,
11
+ } from "@remit/data-ports";
5
12
  import {
13
+ FilterMatchOperator,
14
+ FilterScope,
15
+ FilterState,
16
+ } from "@remit/domain-enums";
17
+ import type { AnchorPayload } from "@remit/search-service";
18
+ import {
19
+ createFilterWithAnchor,
20
+ type FilterCrudDeps,
6
21
  pickFilterUpdate,
7
22
  rejectAnchorMutation,
8
23
  resolveFilterScopeExpiry,
@@ -163,3 +178,139 @@ describe("resolveFilterScopeExpiry (reader #266)", () => {
163
178
  );
164
179
  });
165
180
  });
181
+
182
+ describe("createFilterWithAnchor (#351)", () => {
183
+ const ACCOUNT_CONFIG_ID = "acct-1";
184
+
185
+ const baseInput: CreateFilterInput = {
186
+ name: "Booking confirmations",
187
+ scope: FilterScope.Standing,
188
+ matchOperator: FilterMatchOperator.And,
189
+ literalClauses: [],
190
+ actionLabelId: "None",
191
+ actionMailboxId: "None",
192
+ };
193
+
194
+ const baseFilter: FilterItem = {
195
+ filterId: "filter-1",
196
+ accountConfigId: ACCOUNT_CONFIG_ID,
197
+ name: baseInput.name,
198
+ scope: FilterScope.Standing,
199
+ state: FilterState.Active,
200
+ hasAnchor: false,
201
+ ruleChangedAt: 1_700_000_000,
202
+ matchOperator: FilterMatchOperator.And,
203
+ literalClauses: [],
204
+ actionLabelId: "None",
205
+ actionMailboxId: "None",
206
+ createdAt: 1_700_000_000_000,
207
+ updatedAt: 1_700_000_000_000,
208
+ };
209
+
210
+ const anchorPayload: AnchorPayload = {
211
+ anchorEmbedding: [0.1, 0.2, 0.3],
212
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
213
+ anchorSourceText: "Your booking is confirmed",
214
+ };
215
+
216
+ type AnchorArg = Omit<CreateFilterAnchorInput, "filterId"> | null;
217
+ const createWithAnchorMock = (
218
+ impl: (
219
+ filter: FilterAnchorTxCreateInput,
220
+ anchor: AnchorArg,
221
+ ) => Promise<FilterItem>,
222
+ ) => mock.fn(impl);
223
+
224
+ it("creates a purely-literal filter through one atomic call when there is no anchor message", async () => {
225
+ const createWithAnchor = createWithAnchorMock(async () => baseFilter);
226
+ const deps: FilterCrudDeps = {
227
+ filterAnchorTransaction: { createWithAnchor },
228
+ buildAnchor: mock.fn(async () => null),
229
+ };
230
+
231
+ const filter = await createFilterWithAnchor(
232
+ deps,
233
+ ACCOUNT_CONFIG_ID,
234
+ baseInput,
235
+ );
236
+
237
+ assert.equal(filter, baseFilter);
238
+ assert.equal(createWithAnchor.mock.calls.length, 1);
239
+ const [filterArg, anchorArg] = createWithAnchor.mock.calls[0].arguments;
240
+ assert.equal(filterArg.hasAnchor, false);
241
+ assert.equal(anchorArg, null);
242
+ });
243
+
244
+ it("passes the built anchor and the new hasAnchor flag to the same atomic call", async () => {
245
+ const createWithAnchor = createWithAnchorMock(async () => ({
246
+ ...baseFilter,
247
+ hasAnchor: true,
248
+ }));
249
+ const buildAnchor = mock.fn(async () => anchorPayload);
250
+ const deps: FilterCrudDeps = {
251
+ filterAnchorTransaction: { createWithAnchor },
252
+ buildAnchor,
253
+ };
254
+ const input: CreateFilterInput = {
255
+ ...baseInput,
256
+ anchorMessageId: "msg-1",
257
+ };
258
+
259
+ const filter = await createFilterWithAnchor(deps, ACCOUNT_CONFIG_ID, input);
260
+
261
+ assert.equal(filter.hasAnchor, true);
262
+ assert.equal(buildAnchor.mock.calls.length, 1);
263
+ assert.deepEqual(buildAnchor.mock.calls[0].arguments, [
264
+ ACCOUNT_CONFIG_ID,
265
+ "msg-1",
266
+ ]);
267
+
268
+ assert.equal(createWithAnchor.mock.calls.length, 1);
269
+ const [filterArg, anchorArg] = createWithAnchor.mock.calls[0].arguments;
270
+ assert.equal(filterArg.hasAnchor, true);
271
+ assert.deepEqual(anchorArg, {
272
+ accountConfigId: ACCOUNT_CONFIG_ID,
273
+ anchorMessageId: "msg-1",
274
+ anchorEmbedding: anchorPayload.anchorEmbedding,
275
+ anchorEmbeddingId: anchorPayload.anchorEmbeddingId,
276
+ anchorSourceText: anchorPayload.anchorSourceText,
277
+ });
278
+ });
279
+
280
+ it("creates a purely-literal filter when the anchor message has no indexed chunks", async () => {
281
+ const createWithAnchor = createWithAnchorMock(async () => baseFilter);
282
+ const deps: FilterCrudDeps = {
283
+ filterAnchorTransaction: { createWithAnchor },
284
+ buildAnchor: mock.fn(async () => null),
285
+ };
286
+ const input: CreateFilterInput = {
287
+ ...baseInput,
288
+ anchorMessageId: "msg-with-no-chunks",
289
+ };
290
+
291
+ await createFilterWithAnchor(deps, ACCOUNT_CONFIG_ID, input);
292
+
293
+ const [filterArg, anchorArg] = createWithAnchor.mock.calls[0].arguments;
294
+ assert.equal(filterArg.hasAnchor, false);
295
+ assert.equal(anchorArg, null);
296
+ });
297
+
298
+ it("propagates a failed atomic write as a failed create request, not a silent partial success", async () => {
299
+ const createWithAnchor = createWithAnchorMock(async () => {
300
+ throw new Error("anchor write failed");
301
+ });
302
+ const deps: FilterCrudDeps = {
303
+ filterAnchorTransaction: { createWithAnchor },
304
+ buildAnchor: mock.fn(async () => anchorPayload),
305
+ };
306
+ const input: CreateFilterInput = {
307
+ ...baseInput,
308
+ anchorMessageId: "msg-1",
309
+ };
310
+
311
+ await assert.rejects(
312
+ createFilterWithAnchor(deps, ACCOUNT_CONFIG_ID, input),
313
+ /anchor write failed/,
314
+ );
315
+ });
316
+ });
@@ -3,7 +3,11 @@ import type {
3
3
  FilterResponse,
4
4
  UpdateFilterInput as UpdateFilterRequestBody,
5
5
  } from "@remit/api-openapi-types";
6
- import type { FilterItem, UpdateFilterInput } from "@remit/data-ports";
6
+ import type {
7
+ FilterItem,
8
+ IFilterAnchorTransaction,
9
+ UpdateFilterInput,
10
+ } from "@remit/data-ports";
7
11
  import { BadRequestError } from "@remit/data-ports/errors";
8
12
  import { FilterScope, FilterState } from "@remit/domain-enums";
9
13
  import type { AnchorPayload } from "@remit/search-service";
@@ -19,46 +23,18 @@ import type {
19
23
  import { assertAccountOwnership } from "./account-ownership.js";
20
24
 
21
25
  /**
22
- * Minimal filter-service surface the CRUD handlers need — declared as a `Pick`
23
- * so tests can stub it without a live table.
26
+ * Minimal filter-service surface the create handler needs — declared as a
27
+ * `Pick` so tests can stub it without a live table.
24
28
  */
25
29
  export interface FilterCrudDeps {
26
- filter: {
27
- create(input: {
28
- accountConfigId: string;
29
- name: string;
30
- scope: FilterItem["scope"];
31
- expiresAt?: string;
32
- ttl?: number;
33
- matchOperator: FilterItem["matchOperator"];
34
- literalClauses: FilterItem["literalClauses"];
35
- actionLabelId: string;
36
- actionMailboxId: string;
37
- hasAnchor: boolean;
38
- }): Promise<FilterItem>;
39
- get(accountConfigId: string, filterId: string): Promise<FilterItem>;
40
- update(
41
- accountConfigId: string,
42
- filterId: string,
43
- input: UpdateFilterInput,
44
- ): Promise<FilterItem>;
45
- delete(accountConfigId: string, filterId: string): Promise<void>;
46
- refreshExpiry(item: FilterItem): Promise<FilterItem>;
47
- listPageByAccountConfig(
48
- accountConfigId: string,
49
- options?: { limit?: number; continuationToken?: string },
50
- ): Promise<{ items: FilterItem[]; continuationToken: string | undefined }>;
51
- };
52
- filterAnchor: {
53
- put(input: {
54
- accountConfigId: string;
55
- filterId: string;
56
- anchorMessageId: string;
57
- anchorEmbedding: number[];
58
- anchorEmbeddingId: string;
59
- anchorSourceText: string;
60
- }): Promise<unknown>;
61
- };
30
+ /**
31
+ * Creates the `Filter` row and its optional sibling `FilterAnchor` row in
32
+ * one transaction (#351) — a failure on the anchor write can never leave a
33
+ * `Filter` durably marked `hasAnchor: true` with no matching anchor row,
34
+ * and it surfaces as a failed create request rather than a silently
35
+ * broken filter.
36
+ */
37
+ filterAnchorTransaction: IFilterAnchorTransaction;
62
38
  buildAnchor(
63
39
  accountConfigId: string,
64
40
  anchorMessageId: string,
@@ -221,12 +197,11 @@ const toFilterResponse = (item: FilterItem): FilterResponse => ({
221
197
 
222
198
  /**
223
199
  * Create a filter, wiring the semantic anchor when `anchorMessageId` is set
224
- * (RFC 034 Decision 2). The anchor is built first, before the row is written,
225
- * so `hasAnchor` is set correctly at creation and the sibling `FilterAnchor`
226
- * row is written against the new `filterId` never a second round-trip that
227
- * would bump `ruleChangedAt`. A message with no indexed chunks yields no anchor:
228
- * the filter is created as purely literal (`hasAnchor: false`) rather than with
229
- * an empty anchor.
200
+ * (RFC 034 Decision 2). The anchor is built first, then the `Filter` row and
201
+ * its sibling `FilterAnchor` row are written together in one transaction
202
+ * (#351) never as two independent writes a partial failure could split. A
203
+ * message with no indexed chunks yields no anchor: the filter is created as
204
+ * purely literal (`hasAnchor: false`) rather than with an empty anchor.
230
205
  */
231
206
  export const createFilterWithAnchor = async (
232
207
  deps: FilterCrudDeps,
@@ -238,31 +213,29 @@ export const createFilterWithAnchor = async (
238
213
  ? await deps.buildAnchor(accountConfigId, anchorMessageId)
239
214
  : null;
240
215
 
241
- const filter = await deps.filter.create({
242
- accountConfigId,
243
- name: input.name,
244
- scope: input.scope,
245
- expiresAt: input.expiresAt,
246
- ttl: deriveFilterTtl(input.scope, input.expiresAt),
247
- matchOperator: input.matchOperator,
248
- literalClauses: input.literalClauses,
249
- actionLabelId: input.actionLabelId,
250
- actionMailboxId: input.actionMailboxId,
251
- hasAnchor: anchor !== null,
252
- });
253
-
254
- if (anchor && anchorMessageId) {
255
- await deps.filterAnchor.put({
216
+ return deps.filterAnchorTransaction.createWithAnchor(
217
+ {
256
218
  accountConfigId,
257
- filterId: filter.filterId,
258
- anchorMessageId,
259
- anchorEmbedding: anchor.anchorEmbedding,
260
- anchorEmbeddingId: anchor.anchorEmbeddingId,
261
- anchorSourceText: anchor.anchorSourceText,
262
- });
263
- }
264
-
265
- return filter;
219
+ name: input.name,
220
+ scope: input.scope,
221
+ expiresAt: input.expiresAt,
222
+ ttl: deriveFilterTtl(input.scope, input.expiresAt),
223
+ matchOperator: input.matchOperator,
224
+ literalClauses: input.literalClauses,
225
+ actionLabelId: input.actionLabelId,
226
+ actionMailboxId: input.actionMailboxId,
227
+ hasAnchor: anchor !== null,
228
+ },
229
+ anchor && anchorMessageId
230
+ ? {
231
+ accountConfigId,
232
+ anchorMessageId,
233
+ anchorEmbedding: anchor.anchorEmbedding,
234
+ anchorEmbeddingId: anchor.anchorEmbeddingId,
235
+ anchorSourceText: anchor.anchorSourceText,
236
+ }
237
+ : null,
238
+ );
266
239
  };
267
240
 
268
241
  export const FilterOperations: Record<
@@ -311,8 +284,7 @@ export const FilterOperations: Record<
311
284
 
312
285
  const filter = await createFilterWithAnchor(
313
286
  {
314
- filter: client.filter,
315
- filterAnchor: client.filterAnchor,
287
+ filterAnchorTransaction: client.filterAnchorTransaction,
316
288
  buildAnchor: buildFilterAnchor,
317
289
  },
318
290
  accountConfigId,
@@ -5,6 +5,7 @@ import {
5
5
  AccountSettingRepo,
6
6
  AddressRepo,
7
7
  DrizzleEnvelopeRepository,
8
+ DrizzleFilterAnchorTransaction,
8
9
  DrizzleMessageFlagRepository,
9
10
  DrizzleMessageRepository,
10
11
  DrizzleThreadMessageRepository,
@@ -65,6 +66,7 @@ export const buildPostgresClient = (): RemitClient => {
65
66
  flagPush: new MessageFlagPushRepo(genericDb),
66
67
  filter: new FilterRepo(genericDb),
67
68
  filterAnchor: new FilterAnchorRepo(genericDb),
69
+ filterAnchorTransaction: new DrizzleFilterAnchorTransaction(genericDb),
68
70
  label: new LabelRepo(genericDb),
69
71
  messageLabel: new MessageLabelRepo(genericDb),
70
72
  unitOfWork: new DrizzleUnitOfWork(messageDataDb),
@@ -6,6 +6,7 @@ import {
6
6
  AddressRepo,
7
7
  createSqliteDatabase,
8
8
  DrizzleEnvelopeRepository,
9
+ DrizzleFilterAnchorTransaction,
9
10
  DrizzleMessageFlagRepository,
10
11
  DrizzleMessageRepository,
11
12
  DrizzleThreadMessageRepository,
@@ -73,6 +74,7 @@ export const buildSqliteClient = async (): Promise<RemitClient> => {
73
74
  flagPush: new MessageFlagPushRepo(genericDb),
74
75
  filter: new FilterRepo(genericDb),
75
76
  filterAnchor: new FilterAnchorRepo(genericDb),
77
+ filterAnchorTransaction: new DrizzleFilterAnchorTransaction(genericDb),
76
78
  label: new LabelRepo(genericDb),
77
79
  messageLabel: new MessageLabelRepo(genericDb),
78
80
  unitOfWork: new DrizzleUnitOfWork(messageDataDb),
@@ -6,6 +6,7 @@ import type {
6
6
  IAddressRepository,
7
7
  IEnvelopeRepository,
8
8
  IFilterAnchorRepository,
9
+ IFilterAnchorTransaction,
9
10
  IFilterRepository,
10
11
  ILabelRepository,
11
12
  IMailboxLockRepository,
@@ -90,6 +91,11 @@ export interface RemitClient {
90
91
  // on the row still existing, so the missing reaper is housekeeping only.
91
92
  filter: IFilterRepository;
92
93
  filterAnchor: IFilterAnchorRepository;
94
+
95
+ // Atomically creates a Filter and its optional sibling FilterAnchor row in
96
+ // one transaction (#351) — a failed anchor write can never leave a Filter
97
+ // durably marked hasAnchor: true with no matching FilterAnchor row.
98
+ filterAnchorTransaction: IFilterAnchorTransaction;
93
99
  label: ILabelRepository;
94
100
  messageLabel: IMessageLabelRepository;
95
101
 
@@ -163,6 +169,7 @@ export interface RemitClientRepositories {
163
169
  flagPush: IMessageFlagPushRepository;
164
170
  filter: IFilterRepository;
165
171
  filterAnchor: IFilterAnchorRepository;
172
+ filterAnchorTransaction: IFilterAnchorTransaction;
166
173
  label: ILabelRepository;
167
174
  messageLabel: IMessageLabelRepository;
168
175
  unitOfWork?: IUnitOfWork;
@@ -365,6 +372,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
365
372
  organizeJobRequest: repositories.organizeJobRequest,
366
373
  filter: repositories.filter,
367
374
  filterAnchor: repositories.filterAnchor,
375
+ filterAnchorTransaction: repositories.filterAnchorTransaction,
368
376
  label: repositories.label,
369
377
  messageLabel: repositories.messageLabel,
370
378
  unitOfWork: repositories.unitOfWork,