@remit/backend 0.0.42 → 0.0.44
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 +1 -1
- package/src/derive/enrichThreadRows.ts +12 -18
- package/src/derive/filterThreadCriteria.test.ts +4 -8
- package/src/derive/filterThreadCriteria.ts +11 -20
- package/src/handlers/filter.test.ts +154 -3
- package/src/handlers/filter.ts +43 -71
- package/src/handlers/thread.test.ts +162 -0
- package/src/handlers/thread.ts +3 -1
- package/src/handlers/unified-threads.ts +1 -1
- package/src/service/compose-postgres.ts +2 -0
- package/src/service/compose-sqlite.ts +2 -0
- package/src/service/create-remit-client.ts +8 -0
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
ThreadMessageItem,
|
|
8
8
|
} from "@remit/data-ports";
|
|
9
9
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
10
|
-
import {
|
|
10
|
+
import { SenderTrust, StarColor } from "@remit/domain-enums";
|
|
11
11
|
import { deriveAutoMoved } from "./autoMoved.js";
|
|
12
12
|
import { deriveSenderTrust } from "./senderTrust.js";
|
|
13
13
|
|
|
@@ -50,6 +50,7 @@ const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
|
|
|
50
50
|
hasStars: item.hasStars,
|
|
51
51
|
isDeleted: item.isDeleted,
|
|
52
52
|
snippet: item.snippet,
|
|
53
|
+
category: item.category,
|
|
53
54
|
createdAt: item.createdAt,
|
|
54
55
|
updatedAt: item.updatedAt,
|
|
55
56
|
senderTrust: SenderTrust.Unknown,
|
|
@@ -97,19 +98,20 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
|
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
|
-
* Enrich a page of ThreadMessage rows with `
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
101
|
+
* Enrich a page of ThreadMessage rows with `senderTrust` (derived from the From
|
|
102
|
+
* Address's flags map), `authenticity` and `autoMoved` (both projected from the
|
|
103
|
+
* Message row, see `deriveAutoMoved`).
|
|
104
|
+
*
|
|
105
|
+
* `category` is not enriched: it is denormalized onto the ThreadMessage row and
|
|
106
|
+
* carried straight through by `toResponse`, so the value a client renders is the
|
|
107
|
+
* value the category filter matched.
|
|
104
108
|
*
|
|
105
109
|
* Two BatchGetItem calls per page, regardless of page size — see
|
|
106
110
|
* `planBatchFetch` for the dedup contract.
|
|
107
111
|
*
|
|
108
|
-
* Missing rows fall back gracefully: `
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* `senderTrust` defaults to `"unknown"`. `autoMoved` is omitted whenever the
|
|
112
|
-
* move isn't a real, in-effect auto-move (or the Message row is absent).
|
|
112
|
+
* Missing rows fall back gracefully: `senderTrust` defaults to `"unknown"`, and
|
|
113
|
+
* `authenticity` / `autoMoved` are omitted whenever the Message row is absent or
|
|
114
|
+
* the move isn't a real, in-effect auto-move.
|
|
113
115
|
*
|
|
114
116
|
* Not annotated `Promise<ThreadMessageResponse[]>`: `labels` is a new field on
|
|
115
117
|
* it in this same PR, and that package publishes separately from this repo —
|
|
@@ -158,12 +160,6 @@ export const enrichThreadRows = async (
|
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
const categoryByMessageId = new Map(
|
|
162
|
-
messages.map((m) => [
|
|
163
|
-
m.messageId,
|
|
164
|
-
m.category ?? MessageCategory.uncategorized,
|
|
165
|
-
]),
|
|
166
|
-
);
|
|
167
163
|
const authenticityByMessageId = new Map(
|
|
168
164
|
messages.map((m) => [m.messageId, m.authenticity]),
|
|
169
165
|
);
|
|
@@ -176,7 +172,6 @@ export const enrichThreadRows = async (
|
|
|
176
172
|
|
|
177
173
|
return rows.map((row) => {
|
|
178
174
|
const base = toResponse(row);
|
|
179
|
-
const category = categoryByMessageId.get(row.messageId);
|
|
180
175
|
const authenticity = authenticityByMessageId.get(row.messageId);
|
|
181
176
|
const autoMoved = autoMovedByMessageId.get(row.messageId);
|
|
182
177
|
const addressId = plan.addressIdByRow.get(row.threadMessageId);
|
|
@@ -186,7 +181,6 @@ export const enrichThreadRows = async (
|
|
|
186
181
|
const labels = labelsByMessageId.get(row.messageId);
|
|
187
182
|
return {
|
|
188
183
|
...base,
|
|
189
|
-
...(category !== undefined ? { category } : {}),
|
|
190
184
|
...(authenticity !== undefined ? { authenticity } : {}),
|
|
191
185
|
...(autoMoved !== undefined ? { autoMoved } : {}),
|
|
192
186
|
...(labels !== undefined ? { labels } : {}),
|
|
@@ -25,6 +25,7 @@ const row = (
|
|
|
25
25
|
star: StarColor.None,
|
|
26
26
|
isDeleted: false,
|
|
27
27
|
snippet: "",
|
|
28
|
+
category: MessageCategory.uncategorized,
|
|
28
29
|
createdAt: 0,
|
|
29
30
|
updatedAt: 0,
|
|
30
31
|
senderTrust: SenderTrust.Unknown,
|
|
@@ -37,7 +38,7 @@ describe("hasOffRowCriteria", () => {
|
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
it("is false for empty arrays", () => {
|
|
40
|
-
assert.equal(hasOffRowCriteria({ senderTrust: []
|
|
41
|
+
assert.equal(hasOffRowCriteria({ senderTrust: [] }), false);
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
it("is true when any criterion is set", () => {
|
|
@@ -65,17 +66,12 @@ describe("filterByOffRowCriteria", () => {
|
|
|
65
66
|
assert.ok(result.every((r) => r.senderTrust !== SenderTrust.Unknown));
|
|
66
67
|
});
|
|
67
68
|
|
|
68
|
-
it("
|
|
69
|
+
it("leaves category alone — it is a SQL predicate, not an off-row criterion", () => {
|
|
69
70
|
const rows = [
|
|
70
71
|
row({ category: MessageCategory.newsletter }),
|
|
71
72
|
row({ category: MessageCategory.personal }),
|
|
72
|
-
row({ category: undefined }),
|
|
73
73
|
];
|
|
74
|
-
|
|
75
|
-
category: [MessageCategory.newsletter],
|
|
76
|
-
});
|
|
77
|
-
assert.equal(result.length, 1);
|
|
78
|
-
assert.equal(result[0].category, MessageCategory.newsletter);
|
|
74
|
+
assert.equal(filterByOffRowCriteria(rows, {}).length, 2);
|
|
79
75
|
});
|
|
80
76
|
|
|
81
77
|
it("filters by dkimMismatch and never matches rows lacking an authenticity signal", () => {
|
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
MessageCategory,
|
|
3
2
|
SenderTrust,
|
|
4
3
|
ThreadMessageResponse,
|
|
5
4
|
} from "@remit/api-openapi-types";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Off-row search criteria — fields that live on the underlying Message/Address,
|
|
9
|
-
* not on the ThreadMessage
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* not on the ThreadMessage row. They are resolved by enriching the windowed
|
|
9
|
+
* rows (see enrichThreadRows) and filtering in app code, because no index or
|
|
10
|
+
* FilterExpression can serve them.
|
|
11
|
+
*
|
|
12
|
+
* `senderTrust` derives from AddressItem.flags and `dkimMismatch` from
|
|
13
|
+
* MessageItem.authenticity. `category` is not one of these: it is denormalized
|
|
14
|
+
* onto the ThreadMessage row and filtered in SQL, inside the window.
|
|
12
15
|
*/
|
|
13
16
|
export interface OffRowCriteria {
|
|
14
17
|
senderTrust?: SenderTrust[];
|
|
15
|
-
category?: MessageCategory[];
|
|
16
18
|
dkimMismatch?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export const hasOffRowCriteria = (criteria: OffRowCriteria): boolean =>
|
|
20
|
-
Boolean(criteria.senderTrust?.length) ||
|
|
21
|
-
Boolean(criteria.category?.length) ||
|
|
22
|
-
criteria.dkimMismatch !== undefined;
|
|
22
|
+
Boolean(criteria.senderTrust?.length) || criteria.dkimMismatch !== undefined;
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Filter enriched rows by the off-row criteria. Each active criterion is an
|
|
26
|
-
* any-of set (AND across criteria, OR within a set). A row with no
|
|
27
|
-
* never matches a
|
|
28
|
-
*
|
|
26
|
+
* any-of set (AND across criteria, OR within a set). A row with no
|
|
27
|
+
* `authenticity` signal never matches a `dkimMismatch` filter (absence means no
|
|
28
|
+
* signal, not a verdict).
|
|
29
29
|
*/
|
|
30
30
|
export const filterByOffRowCriteria = (
|
|
31
31
|
rows: ThreadMessageResponse[],
|
|
@@ -36,19 +36,10 @@ export const filterByOffRowCriteria = (
|
|
|
36
36
|
const trustSet = criteria.senderTrust?.length
|
|
37
37
|
? new Set(criteria.senderTrust)
|
|
38
38
|
: undefined;
|
|
39
|
-
const categorySet = criteria.category?.length
|
|
40
|
-
? new Set(criteria.category)
|
|
41
|
-
: undefined;
|
|
42
39
|
const { dkimMismatch } = criteria;
|
|
43
40
|
|
|
44
41
|
return rows.filter((row) => {
|
|
45
42
|
if (trustSet && !trustSet.has(row.senderTrust)) return false;
|
|
46
|
-
if (
|
|
47
|
-
categorySet &&
|
|
48
|
-
(row.category === undefined || !categorySet.has(row.category))
|
|
49
|
-
) {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
43
|
if (
|
|
53
44
|
dkimMismatch !== undefined &&
|
|
54
45
|
row.authenticity?.dkimMismatch !== dkimMismatch
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
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
|
+
});
|
package/src/handlers/filter.ts
CHANGED
|
@@ -3,7 +3,11 @@ import type {
|
|
|
3
3
|
FilterResponse,
|
|
4
4
|
UpdateFilterInput as UpdateFilterRequestBody,
|
|
5
5
|
} from "@remit/api-openapi-types";
|
|
6
|
-
import type {
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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,
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
315
|
-
filterAnchor: client.filterAnchor,
|
|
287
|
+
filterAnchorTransaction: client.filterAnchorTransaction,
|
|
316
288
|
buildAnchor: buildFilterAnchor,
|
|
317
289
|
},
|
|
318
290
|
accountConfigId,
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
4
|
+
import { MessageCategory, SenderTrust, StarColor } from "@remit/domain-enums";
|
|
3
5
|
import {
|
|
4
6
|
buildListThreadMessagesOptions,
|
|
7
|
+
buildListThreadsOptions,
|
|
8
|
+
buildSearchThreadsOptions,
|
|
5
9
|
dedupeThreadMessages,
|
|
10
|
+
executeThreadSearch,
|
|
11
|
+
type ThreadSearchClient,
|
|
6
12
|
} from "./thread.js";
|
|
7
13
|
|
|
14
|
+
const ACCOUNT = "cfg-1";
|
|
15
|
+
const MAILBOX = "mbx-inbox";
|
|
16
|
+
|
|
8
17
|
type Row = {
|
|
9
18
|
threadMessageId: string;
|
|
10
19
|
messageIdHeader?: string;
|
|
@@ -84,3 +93,156 @@ describe("dedupeThreadMessages", () => {
|
|
|
84
93
|
assert.deepEqual(dedupeThreadMessages([]), []);
|
|
85
94
|
});
|
|
86
95
|
});
|
|
96
|
+
|
|
97
|
+
// #304: `category` is a column on the thread_message row, so it is a SQL
|
|
98
|
+
// predicate the port applies inside its window — not a criterion resolved by
|
|
99
|
+
// enriching whatever the window happened to return. These assertions are on the
|
|
100
|
+
// routing, because that is what a later refactor can silently undo: the SQL
|
|
101
|
+
// clause goes dead and the filter falls back to a window-sized filter with no
|
|
102
|
+
// test failing.
|
|
103
|
+
describe("executeThreadSearch", () => {
|
|
104
|
+
type Category = ThreadMessageItem["category"];
|
|
105
|
+
|
|
106
|
+
const threadRow = (
|
|
107
|
+
threadMessageId: string,
|
|
108
|
+
category: Category,
|
|
109
|
+
): ThreadMessageItem => ({
|
|
110
|
+
threadMessageId,
|
|
111
|
+
threadId: `t-${threadMessageId}`,
|
|
112
|
+
messageId: `m-${threadMessageId}`,
|
|
113
|
+
accountConfigId: ACCOUNT,
|
|
114
|
+
mailboxId: MAILBOX,
|
|
115
|
+
uid: 1,
|
|
116
|
+
referenceOrder: 0,
|
|
117
|
+
internalDate: 0,
|
|
118
|
+
sentDate: 0,
|
|
119
|
+
isRead: false,
|
|
120
|
+
hasAttachment: false,
|
|
121
|
+
star: StarColor.None,
|
|
122
|
+
hasStars: false,
|
|
123
|
+
isDeleted: false,
|
|
124
|
+
category,
|
|
125
|
+
createdAt: 0,
|
|
126
|
+
updatedAt: 0,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
type RecordedCall = { search: { category?: Category[] } };
|
|
130
|
+
|
|
131
|
+
const fakeClient = (rows: ThreadMessageItem[]) => {
|
|
132
|
+
const windowCalls: RecordedCall[] = [];
|
|
133
|
+
const countCalls: RecordedCall[] = [];
|
|
134
|
+
|
|
135
|
+
// The fake applies the category predicate itself, the way a port does, so
|
|
136
|
+
// a request that never reaches `search` cannot answer correctly by
|
|
137
|
+
// accident.
|
|
138
|
+
const matching = (categories?: Category[]) =>
|
|
139
|
+
categories?.length
|
|
140
|
+
? rows.filter((row) => categories.includes(row.category))
|
|
141
|
+
: rows;
|
|
142
|
+
|
|
143
|
+
const client: ThreadSearchClient = {
|
|
144
|
+
threadMessage: {
|
|
145
|
+
async searchByMailboxWindow(_account, _mailbox, search) {
|
|
146
|
+
windowCalls.push({ search });
|
|
147
|
+
return {
|
|
148
|
+
items: matching(search.category),
|
|
149
|
+
continuationToken: undefined,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
async countByMailbox(_account, _mailbox, search) {
|
|
153
|
+
countCalls.push({ search });
|
|
154
|
+
return matching(search.category).length;
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
message: { get: async () => [] },
|
|
158
|
+
address: { getAddress: async () => [] },
|
|
159
|
+
messageLabel: { listByMessageIds: async () => [] },
|
|
160
|
+
label: { listByAccountConfig: async () => [] },
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return { client, windowCalls, countCalls };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const ACCOUNT_ROWS = [
|
|
167
|
+
threadRow("tm-1", MessageCategory.personal),
|
|
168
|
+
threadRow("tm-2", MessageCategory.marketing),
|
|
169
|
+
threadRow("tm-3", MessageCategory.uncategorized),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
it("passes category to the port's search, not to the off-row filter", async () => {
|
|
173
|
+
const { client, windowCalls } = fakeClient(ACCOUNT_ROWS);
|
|
174
|
+
|
|
175
|
+
const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {
|
|
176
|
+
category: [MessageCategory.personal],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.deepEqual(windowCalls.length, 1);
|
|
180
|
+
assert.deepEqual(windowCalls[0].search.category, [
|
|
181
|
+
MessageCategory.personal,
|
|
182
|
+
]);
|
|
183
|
+
assert.deepEqual(
|
|
184
|
+
response.items?.map((item) => item.threadMessageId),
|
|
185
|
+
["tm-1"],
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("takes the count-only path for a category-only query", async () => {
|
|
190
|
+
const { client, windowCalls, countCalls } = fakeClient(ACCOUNT_ROWS);
|
|
191
|
+
|
|
192
|
+
const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {
|
|
193
|
+
category: [MessageCategory.personal, MessageCategory.marketing],
|
|
194
|
+
count: true,
|
|
195
|
+
results: false,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
assert.equal(windowCalls.length, 0, "no window read in count-only mode");
|
|
199
|
+
assert.equal(countCalls.length, 1);
|
|
200
|
+
assert.deepEqual(countCalls[0].search.category, [
|
|
201
|
+
MessageCategory.personal,
|
|
202
|
+
MessageCategory.marketing,
|
|
203
|
+
]);
|
|
204
|
+
assert.equal(response.count, 2);
|
|
205
|
+
assert.equal(response.items, undefined);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// The one request shape that still takes the off-row branch. `category` must
|
|
209
|
+
// reach `search` anyway, so the window is category-filtered before the
|
|
210
|
+
// enrichment the off-row criterion needs.
|
|
211
|
+
it("keeps category in search when an off-row criterion is also set", async () => {
|
|
212
|
+
const { client, windowCalls } = fakeClient(ACCOUNT_ROWS);
|
|
213
|
+
|
|
214
|
+
const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {
|
|
215
|
+
category: [MessageCategory.personal],
|
|
216
|
+
senderTrust: [SenderTrust.Unknown],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
assert.equal(windowCalls.length, 1);
|
|
220
|
+
assert.deepEqual(windowCalls[0].search.category, [
|
|
221
|
+
MessageCategory.personal,
|
|
222
|
+
]);
|
|
223
|
+
assert.deepEqual(
|
|
224
|
+
response.items?.map((item) => item.threadMessageId),
|
|
225
|
+
["tm-1"],
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("serves category from the row, so it survives an absent message row", async () => {
|
|
230
|
+
const { client } = fakeClient(ACCOUNT_ROWS);
|
|
231
|
+
|
|
232
|
+
const response = await executeThreadSearch(client, ACCOUNT, MAILBOX, {});
|
|
233
|
+
|
|
234
|
+
assert.deepEqual(
|
|
235
|
+
response.items?.map((item) => item.category),
|
|
236
|
+
[
|
|
237
|
+
MessageCategory.personal,
|
|
238
|
+
MessageCategory.marketing,
|
|
239
|
+
MessageCategory.uncategorized,
|
|
240
|
+
],
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("projects category, so the DynamoDB port reads it with the row", () => {
|
|
245
|
+
assert.ok(buildSearchThreadsOptions({}).attributes.includes("category"));
|
|
246
|
+
assert.ok(buildListThreadsOptions({}).attributes.includes("category"));
|
|
247
|
+
});
|
|
248
|
+
});
|
package/src/handlers/thread.ts
CHANGED
|
@@ -48,6 +48,7 @@ const THREAD_LIST_ATTRIBUTES: ReadonlyArray<keyof ThreadMessageItem> = [
|
|
|
48
48
|
"hasStars",
|
|
49
49
|
"isDeleted",
|
|
50
50
|
"snippet",
|
|
51
|
+
"category",
|
|
51
52
|
"createdAt",
|
|
52
53
|
"updatedAt",
|
|
53
54
|
];
|
|
@@ -109,6 +110,7 @@ type ThreadSearch = {
|
|
|
109
110
|
unread?: boolean;
|
|
110
111
|
starred?: boolean;
|
|
111
112
|
attachments?: boolean;
|
|
113
|
+
category?: MessageCategory[];
|
|
112
114
|
};
|
|
113
115
|
|
|
114
116
|
/**
|
|
@@ -174,10 +176,10 @@ export const executeThreadSearch = async (
|
|
|
174
176
|
unread: params.unread,
|
|
175
177
|
starred: params.starred,
|
|
176
178
|
attachments: params.attachments,
|
|
179
|
+
category: params.category,
|
|
177
180
|
};
|
|
178
181
|
const offRow = {
|
|
179
182
|
senderTrust: params.senderTrust,
|
|
180
|
-
category: params.category,
|
|
181
183
|
dkimMismatch: params.dkimMismatch,
|
|
182
184
|
};
|
|
183
185
|
const wantCount = params.count === true;
|
|
@@ -325,7 +325,7 @@ export const dedupeByMessageId = <T extends { messageId: string }>(
|
|
|
325
325
|
/**
|
|
326
326
|
* Attach accountId to each enriched ThreadMessageResponse row using the
|
|
327
327
|
* mailboxId→accountId map built from inbox discovery. Same read-time-attach
|
|
328
|
-
* pattern as senderTrust
|
|
328
|
+
* pattern as senderTrust in enrichThreadRows.
|
|
329
329
|
*/
|
|
330
330
|
export const attachAccountIds = (
|
|
331
331
|
rows: Awaited<ReturnType<typeof enrichThreadRows>>,
|
|
@@ -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,
|