@remit/backend 0.0.40 → 0.0.42
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/dev-server/server.ts +37 -0
- package/dev-server/sync-age.test.ts +91 -0
- package/dev-server/sync-age.ts +55 -0
- package/package.json +1 -1
- package/src/service/organize.test.ts +320 -11
- package/src/service/organize.ts +267 -15
package/dev-server/server.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { logger } from "@remit/logger-lambda";
|
|
4
|
+
import {
|
|
5
|
+
metricsContentType,
|
|
6
|
+
onScrape,
|
|
7
|
+
renderMetrics,
|
|
8
|
+
setAccountSyncAges,
|
|
9
|
+
} from "@remit/logger-lambda/metrics";
|
|
3
10
|
import { isStorageNotFoundError } from "@remit/storage-service";
|
|
4
11
|
import type { APIGatewayProxyResult } from "aws-lambda";
|
|
5
12
|
import { env } from "expect-env";
|
|
@@ -17,6 +24,7 @@ import { resolveContentPath } from "./content-path.js";
|
|
|
17
24
|
import { parseAllowedOrigins, resolveAllowOrigin } from "./cors.js";
|
|
18
25
|
import { createLambdaContext, createLambdaEvent } from "./lambda-helpers.js";
|
|
19
26
|
import { checkRelationalStore } from "./relational-health.js";
|
|
27
|
+
import { collectAccountSyncAges } from "./sync-age.js";
|
|
20
28
|
|
|
21
29
|
const app = express();
|
|
22
30
|
|
|
@@ -137,6 +145,35 @@ app.get("/health", async (_req: Request, res: Response) => {
|
|
|
137
145
|
});
|
|
138
146
|
});
|
|
139
147
|
|
|
148
|
+
// The scrape endpoint (standalone-observability D2), on the port this server
|
|
149
|
+
// already serves on and never routed through Caddy — deploy/vps/caddy/routes.caddy
|
|
150
|
+
// proxies /api/*, /content/* and /health, and everything else goes to the static
|
|
151
|
+
// web server, so there is no path from the public origin to this route.
|
|
152
|
+
//
|
|
153
|
+
// The per-account sync age is a database read, so it is collected when a scrape
|
|
154
|
+
// arrives rather than tracked as syncs complete. A read that fails fails the
|
|
155
|
+
// scrape: a signal that could not be evaluated must not render as a healthy
|
|
156
|
+
// number. Only the self-host backends have a store to read here — the
|
|
157
|
+
// AWS-local dev path composes its client from outside this module.
|
|
158
|
+
if (isSelfHostBackend) {
|
|
159
|
+
onScrape(async () => {
|
|
160
|
+
const client = await getClient();
|
|
161
|
+
setAccountSyncAges(await collectAccountSyncAges(client, Date.now()));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
app.get("/metrics", async (_req: Request, res: Response) => {
|
|
166
|
+
const body = await renderMetrics().catch((error: unknown) => {
|
|
167
|
+
logger.error({ error: String(error) }, "Metrics collection failed");
|
|
168
|
+
return null;
|
|
169
|
+
});
|
|
170
|
+
if (body === null) {
|
|
171
|
+
res.status(500).type("text/plain").send("metrics collection failed\n");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
res.setHeader("content-type", metricsContentType).send(body);
|
|
175
|
+
});
|
|
176
|
+
|
|
140
177
|
// Swagger UI exposes the full API schema, so it must never be on the public
|
|
141
178
|
// surface. On the self-host backends this server is the deployed backend
|
|
142
179
|
// container; gate the docs to the AWS-local dev path only. The generated
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { collectAccountSyncAges, type SyncAgeSource } from "./sync-age.js";
|
|
5
|
+
|
|
6
|
+
const NOW = 1_700_000_000_000;
|
|
7
|
+
|
|
8
|
+
const account = (overrides: Partial<AccountItem>): AccountItem =>
|
|
9
|
+
({
|
|
10
|
+
accountId: "acct-1",
|
|
11
|
+
createdAt: NOW - 600_000,
|
|
12
|
+
lastSyncAt: NOW,
|
|
13
|
+
...overrides,
|
|
14
|
+
}) as AccountItem;
|
|
15
|
+
|
|
16
|
+
const mailbox = (lastMessageSyncAt: number): MailboxItem =>
|
|
17
|
+
({ mailboxId: `mbx-${lastMessageSyncAt}`, lastMessageSyncAt }) as MailboxItem;
|
|
18
|
+
|
|
19
|
+
const source = (
|
|
20
|
+
accounts: AccountItem[],
|
|
21
|
+
mailboxes: Record<string, MailboxItem[]>,
|
|
22
|
+
): SyncAgeSource => ({
|
|
23
|
+
account: { listAll: async () => accounts },
|
|
24
|
+
mailbox: {
|
|
25
|
+
listAllByAccount: async (accountId: string) => mailboxes[accountId] ?? [],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("collectAccountSyncAges", () => {
|
|
30
|
+
it("measures from the newest mailbox message-sync stamp", async () => {
|
|
31
|
+
const ages = await collectAccountSyncAges(
|
|
32
|
+
source([account({})], {
|
|
33
|
+
"acct-1": [mailbox(NOW - 300_000), mailbox(NOW - 60_000)],
|
|
34
|
+
}),
|
|
35
|
+
NOW,
|
|
36
|
+
);
|
|
37
|
+
assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 60 }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ignores account.lastSyncAt, which is stamped before the message fan-out", async () => {
|
|
41
|
+
// lastSyncAt is fresh and every message handler has been failing for an
|
|
42
|
+
// hour. The exported age must report the hour.
|
|
43
|
+
const ages = await collectAccountSyncAges(
|
|
44
|
+
source([account({ lastSyncAt: NOW })], {
|
|
45
|
+
"acct-1": [mailbox(NOW - 3_600_000)],
|
|
46
|
+
}),
|
|
47
|
+
NOW,
|
|
48
|
+
);
|
|
49
|
+
assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 3600 }]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("treats an unstamped mailbox as never synced", async () => {
|
|
53
|
+
const ages = await collectAccountSyncAges(
|
|
54
|
+
source([account({ createdAt: NOW - 900_000 })], {
|
|
55
|
+
"acct-1": [mailbox(0)],
|
|
56
|
+
}),
|
|
57
|
+
NOW,
|
|
58
|
+
);
|
|
59
|
+
assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 900 }]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reports an account with no mailboxes at all rather than omitting it", async () => {
|
|
63
|
+
const ages = await collectAccountSyncAges(
|
|
64
|
+
source([account({ createdAt: NOW - 120_000 })], {}),
|
|
65
|
+
NOW,
|
|
66
|
+
);
|
|
67
|
+
assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 120 }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("skips a deleted account", async () => {
|
|
71
|
+
const ages = await collectAccountSyncAges(
|
|
72
|
+
source(
|
|
73
|
+
[
|
|
74
|
+
account({ accountId: "gone", deletedAt: NOW - 1000 }),
|
|
75
|
+
account({ accountId: "live" }),
|
|
76
|
+
],
|
|
77
|
+
{ live: [mailbox(NOW - 30_000)] },
|
|
78
|
+
),
|
|
79
|
+
NOW,
|
|
80
|
+
);
|
|
81
|
+
assert.deepEqual(ages, [{ accountId: "live", ageSeconds: 30 }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("never reports a negative age when a stamp is ahead of the clock", async () => {
|
|
85
|
+
const ages = await collectAccountSyncAges(
|
|
86
|
+
source([account({})], { "acct-1": [mailbox(NOW + 5_000)] }),
|
|
87
|
+
NOW,
|
|
88
|
+
);
|
|
89
|
+
assert.deepEqual(ages, [{ accountId: "acct-1", ageSeconds: 0 }]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { IAccountRepository, IMailboxRepository } from "@remit/data-ports";
|
|
2
|
+
|
|
3
|
+
export interface SyncAgeSource {
|
|
4
|
+
readonly account: Pick<IAccountRepository, "listAll">;
|
|
5
|
+
readonly mailbox: Pick<IMailboxRepository, "listAllByAccount">;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AccountSyncAge {
|
|
9
|
+
readonly accountId: string;
|
|
10
|
+
readonly ageSeconds: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Seconds since each account last completed a message-sync round
|
|
15
|
+
* (standalone-observability D3).
|
|
16
|
+
*
|
|
17
|
+
* Measured from `mailbox.lastMessageSyncAt`, not `account.lastSyncAt`.
|
|
18
|
+
* `account.lastSyncAt` is stamped after the mailbox *list* sync and before the
|
|
19
|
+
* per-mailbox fan-out that fetches messages, so a deployment whose message
|
|
20
|
+
* handlers all throw keeps it fresh forever while no mail arrives.
|
|
21
|
+
* `lastMessageSyncAt` is stamped at the end of a message-sync round, after the
|
|
22
|
+
* fetch and the writes.
|
|
23
|
+
*
|
|
24
|
+
* The value is the age of the newest such stamp across the account's mailboxes:
|
|
25
|
+
* seconds since this account last completed a round trip that would have found
|
|
26
|
+
* new mail if there were any. An account with no stamped mailbox has never
|
|
27
|
+
* completed one, and reports the age of the account row instead of being
|
|
28
|
+
* omitted — "never synced" is the condition most worth seeing, not a gap in the
|
|
29
|
+
* series.
|
|
30
|
+
*
|
|
31
|
+
* Labelled by account id, never by address: a scraped label travels wherever
|
|
32
|
+
* the scrape goes.
|
|
33
|
+
*/
|
|
34
|
+
export const collectAccountSyncAges = async (
|
|
35
|
+
source: SyncAgeSource,
|
|
36
|
+
now: number,
|
|
37
|
+
): Promise<AccountSyncAge[]> => {
|
|
38
|
+
const accounts = await source.account.listAll();
|
|
39
|
+
const ages: AccountSyncAge[] = [];
|
|
40
|
+
for (const account of accounts) {
|
|
41
|
+
if (account.deletedAt) continue;
|
|
42
|
+
const mailboxes = await source.mailbox.listAllByAccount(account.accountId);
|
|
43
|
+
const stamps = mailboxes
|
|
44
|
+
.map((mailbox) => mailbox.lastMessageSyncAt)
|
|
45
|
+
.filter(
|
|
46
|
+
(stamp): stamp is number => typeof stamp === "number" && stamp > 0,
|
|
47
|
+
);
|
|
48
|
+
const newest = stamps.length > 0 ? Math.max(...stamps) : account.createdAt;
|
|
49
|
+
ages.push({
|
|
50
|
+
accountId: account.accountId,
|
|
51
|
+
ageSeconds: Math.max(0, Math.round((now - newest) / 1000)),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return ages;
|
|
55
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import type { FilterAnchorItem, FilterItem } from "@remit/data-ports";
|
|
4
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
5
|
+
import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
|
|
4
6
|
import type {
|
|
5
7
|
AnchorPayload,
|
|
6
8
|
ChunkMetadata,
|
|
@@ -68,15 +70,51 @@ const predicate = (
|
|
|
68
70
|
...over,
|
|
69
71
|
});
|
|
70
72
|
|
|
73
|
+
/** A standing filter fixture — the "other" filters the precedence check reads. */
|
|
74
|
+
const filterItem = (over: Partial<FilterItem> = {}): FilterItem => ({
|
|
75
|
+
filterId: "filter-other",
|
|
76
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
77
|
+
name: "other filter",
|
|
78
|
+
scope: "Standing",
|
|
79
|
+
state: FilterState.Active,
|
|
80
|
+
hasAnchor: false,
|
|
81
|
+
ruleChangedAt: 0,
|
|
82
|
+
matchOperator: FilterMatchOperator.And,
|
|
83
|
+
literalClauses: [],
|
|
84
|
+
actionLabelId: "None",
|
|
85
|
+
actionMailboxId: "None",
|
|
86
|
+
createdAt: 0,
|
|
87
|
+
updatedAt: 0,
|
|
88
|
+
...over,
|
|
89
|
+
});
|
|
90
|
+
|
|
71
91
|
/**
|
|
72
92
|
* A client that records MessageLabel writes and blows up if the back-apply path
|
|
73
93
|
* ever touches Filter/FilterAnchor — the RFC 034 guardrail: this scope never
|
|
74
94
|
* persists a standing rule.
|
|
95
|
+
*
|
|
96
|
+
* `activeFilters`/`filterAnchorRows`/`threadMessages` model the account's
|
|
97
|
+
* *other*, already-existing standing filters and message rows the exclusive-
|
|
98
|
+
* move precedence check (reader #350) reads — empty by default, so every
|
|
99
|
+
* existing test (which never seeded a competing filter) is unaffected and the
|
|
100
|
+
* precedence check is a same-length no-op.
|
|
75
101
|
*/
|
|
76
|
-
const trackingClient = (
|
|
102
|
+
const trackingClient = (
|
|
103
|
+
seed: {
|
|
104
|
+
activeFilters?: FilterItem[];
|
|
105
|
+
filterAnchorRows?: FilterAnchorItem[];
|
|
106
|
+
threadMessages?: Record<
|
|
107
|
+
string,
|
|
108
|
+
{ fromEmail?: string; fromName?: string; subject?: string }
|
|
109
|
+
>;
|
|
110
|
+
} = {},
|
|
111
|
+
) => {
|
|
77
112
|
const labeled: Array<{ messageId: string; labelId: string }> = [];
|
|
78
113
|
let filterWrites = 0;
|
|
79
114
|
let filterAnchorWrites = 0;
|
|
115
|
+
const activeFilters = seed.activeFilters ?? [];
|
|
116
|
+
const filterAnchorRows = seed.filterAnchorRows ?? [];
|
|
117
|
+
const threadMessages = seed.threadMessages ?? {};
|
|
80
118
|
const client = {
|
|
81
119
|
messageLabel: {
|
|
82
120
|
apply: async (input: {
|
|
@@ -100,17 +138,36 @@ const trackingClient = () => {
|
|
|
100
138
|
mailbox: {
|
|
101
139
|
resolveAccountId: async () => "acct-1",
|
|
102
140
|
},
|
|
141
|
+
threadMessage: {
|
|
142
|
+
get: async (_accountConfigId: string, messageId: string) => {
|
|
143
|
+
const row = threadMessages[messageId];
|
|
144
|
+
if (!row) {
|
|
145
|
+
throw new NotFoundError("ThreadMessage not found");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
threadMessageId: messageId,
|
|
149
|
+
fromEmail: row.fromEmail,
|
|
150
|
+
fromName: row.fromName,
|
|
151
|
+
subject: row.subject,
|
|
152
|
+
} as never;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
103
155
|
filter: {
|
|
104
156
|
create: async () => {
|
|
105
157
|
filterWrites += 1;
|
|
106
158
|
return {} as never;
|
|
107
159
|
},
|
|
160
|
+
listByAccountAndState: async () => activeFilters,
|
|
161
|
+
refreshExpiry: async (filter: FilterItem) => filter,
|
|
108
162
|
},
|
|
109
163
|
filterAnchor: {
|
|
110
164
|
put: async () => {
|
|
111
165
|
filterAnchorWrites += 1;
|
|
112
166
|
return {} as never;
|
|
113
167
|
},
|
|
168
|
+
get: async (_accountConfigId: string, filterId: string) =>
|
|
169
|
+
filterAnchorRows.find((row) => row.filterId === filterId) ?? null,
|
|
170
|
+
listByAccountConfig: async () => filterAnchorRows,
|
|
114
171
|
},
|
|
115
172
|
} as unknown as RemitClient;
|
|
116
173
|
return {
|
|
@@ -160,19 +217,30 @@ const trackingMoveService = () => {
|
|
|
160
217
|
* Deps whose semantic side is the in-memory vector store — the vector-backed
|
|
161
218
|
* deployment. `listAccountFilterMessages` returns the given corpus (empty by
|
|
162
219
|
* default), and constructing the semantic side is tracked so a literal-only
|
|
163
|
-
* predicate can be shown never to build it.
|
|
220
|
+
* predicate can be shown never to build it. `filterAnchorRows` seeds the
|
|
221
|
+
* account's *persisted* FilterAnchor rows (empty by default) — the reverse
|
|
222
|
+
* lookup back-apply's anchor consultation reads (reader #350) — and `embed`
|
|
223
|
+
* is a deterministic stand-in for the configured embedding model, used only
|
|
224
|
+
* by the exclusive-move precedence check.
|
|
164
225
|
*/
|
|
165
226
|
const matchDeps = (
|
|
166
227
|
store: ReturnType<typeof createMemoryVectorStore>,
|
|
167
228
|
corpus: OrganizeCandidate[] = [],
|
|
229
|
+
filterAnchorRows: FilterAnchorItem[] = [],
|
|
168
230
|
): OrganizeMatchDeps & { semanticBuilds: () => number } => {
|
|
169
231
|
let semanticBuilds = 0;
|
|
170
232
|
return {
|
|
171
233
|
semantic: () => {
|
|
172
234
|
semanticBuilds += 1;
|
|
173
|
-
return {
|
|
235
|
+
return {
|
|
236
|
+
buildAnchor: async () => anchorPayload,
|
|
237
|
+
vectorStore: store,
|
|
238
|
+
embed: async (text: string) =>
|
|
239
|
+
text.includes("reservation") ? ANCHOR_VECTOR : ORTHOGONAL_VECTOR,
|
|
240
|
+
};
|
|
174
241
|
},
|
|
175
242
|
listAccountFilterMessages: async () => corpus,
|
|
243
|
+
filterAnchors: { listByAccountConfig: async () => filterAnchorRows },
|
|
176
244
|
semanticBuilds: () => semanticBuilds,
|
|
177
245
|
};
|
|
178
246
|
};
|
|
@@ -201,9 +269,13 @@ const vectorlessDeps = (
|
|
|
201
269
|
throw moduleNotFound();
|
|
202
270
|
},
|
|
203
271
|
},
|
|
272
|
+
embed: async () => {
|
|
273
|
+
throw moduleNotFound();
|
|
274
|
+
},
|
|
204
275
|
};
|
|
205
276
|
},
|
|
206
277
|
listAccountFilterMessages: async () => corpus,
|
|
278
|
+
filterAnchors: { listByAccountConfig: async () => [] },
|
|
207
279
|
semanticUsed: () => semanticUsed,
|
|
208
280
|
};
|
|
209
281
|
};
|
|
@@ -305,6 +377,77 @@ describe("matchOrganize", () => {
|
|
|
305
377
|
});
|
|
306
378
|
});
|
|
307
379
|
|
|
380
|
+
describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () => {
|
|
381
|
+
it("still matches by reading the persisted anchor after the anchor message is purged", async () => {
|
|
382
|
+
const store = createMemoryVectorStore();
|
|
383
|
+
const matching = ["msg-1", "msg-2"];
|
|
384
|
+
await store.upsert([
|
|
385
|
+
...matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)),
|
|
386
|
+
bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
const persistedAnchor: FilterAnchorItem = {
|
|
390
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
391
|
+
filterId: "filter-a",
|
|
392
|
+
anchorEmbedding: ANCHOR_VECTOR,
|
|
393
|
+
anchorEmbeddingId: "test-model@4",
|
|
394
|
+
anchorSourceText: "book me a table",
|
|
395
|
+
anchorMessageId: "msg-anchor",
|
|
396
|
+
createdAt: 0,
|
|
397
|
+
updatedAt: 0,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
let buildAnchorCalls = 0;
|
|
401
|
+
const deps: OrganizeMatchDeps = {
|
|
402
|
+
semantic: () => ({
|
|
403
|
+
buildAnchor: async () => {
|
|
404
|
+
// The live path: the anchor message's chunks are gone (purged),
|
|
405
|
+
// exactly what buildMessageAnchor returns in that case today.
|
|
406
|
+
buildAnchorCalls += 1;
|
|
407
|
+
return null;
|
|
408
|
+
},
|
|
409
|
+
vectorStore: store,
|
|
410
|
+
embed: async () => ANCHOR_VECTOR,
|
|
411
|
+
}),
|
|
412
|
+
listAccountFilterMessages: async () => [],
|
|
413
|
+
filterAnchors: { listByAccountConfig: async () => [persistedAnchor] },
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const { messageIds } = await matchOrganize(
|
|
417
|
+
deps,
|
|
418
|
+
ACCOUNT_CONFIG_ID,
|
|
419
|
+
predicate(),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
assert.deepEqual(
|
|
423
|
+
[...messageIds].sort(),
|
|
424
|
+
matching,
|
|
425
|
+
"the persisted anchor vector still finds the same matches the live message would have",
|
|
426
|
+
);
|
|
427
|
+
assert.equal(
|
|
428
|
+
buildAnchorCalls,
|
|
429
|
+
0,
|
|
430
|
+
"a persisted anchor must be read instead of re-deriving one from the (now-gone) live message",
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("falls back to deriving the anchor from the live message when no filter was ever anchored on it", async () => {
|
|
435
|
+
const store = createMemoryVectorStore();
|
|
436
|
+
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
437
|
+
|
|
438
|
+
// No persisted FilterAnchor names this anchorMessageId — an ad hoc "all
|
|
439
|
+
// like these" widen over a bare message selection, never tied to a
|
|
440
|
+
// standing filter.
|
|
441
|
+
const { messageIds } = await matchOrganize(
|
|
442
|
+
matchDeps(store),
|
|
443
|
+
ACCOUNT_CONFIG_ID,
|
|
444
|
+
predicate(),
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
assert.deepEqual(messageIds, ["msg-1"]);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
308
451
|
describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
309
452
|
const ORIGINAL = process.env.DATA_BACKEND;
|
|
310
453
|
beforeEach(() => {
|
|
@@ -403,8 +546,10 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
403
546
|
query: async () => [],
|
|
404
547
|
getByMessage: async () => [],
|
|
405
548
|
},
|
|
549
|
+
embed: async () => [],
|
|
406
550
|
}),
|
|
407
551
|
listAccountFilterMessages: async () => [],
|
|
552
|
+
filterAnchors: { listByAccountConfig: async () => [] },
|
|
408
553
|
};
|
|
409
554
|
|
|
410
555
|
await assert.rejects(
|
|
@@ -437,7 +582,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
437
582
|
|
|
438
583
|
const tracked = trackingClient();
|
|
439
584
|
const result = await applyOrganize(
|
|
440
|
-
{ client: tracked.client },
|
|
585
|
+
{ client: tracked.client, match: matchDeps(store) },
|
|
441
586
|
ACCOUNT_CONFIG_ID,
|
|
442
587
|
applied.messageIds,
|
|
443
588
|
p,
|
|
@@ -473,7 +618,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
473
618
|
);
|
|
474
619
|
const tracked = trackingClient();
|
|
475
620
|
const result = await applyOrganize(
|
|
476
|
-
{ client: tracked.client },
|
|
621
|
+
{ client: tracked.client, match: matchDeps(store) },
|
|
477
622
|
ACCOUNT_CONFIG_ID,
|
|
478
623
|
matched,
|
|
479
624
|
p,
|
|
@@ -500,7 +645,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
500
645
|
const tracked = trackingClient();
|
|
501
646
|
const mover = trackingMoveService();
|
|
502
647
|
const result = await applyOrganize(
|
|
503
|
-
{
|
|
648
|
+
{
|
|
649
|
+
client: tracked.client,
|
|
650
|
+
moveService: mover.moveService,
|
|
651
|
+
match: matchDeps(store),
|
|
652
|
+
},
|
|
504
653
|
ACCOUNT_CONFIG_ID,
|
|
505
654
|
matched,
|
|
506
655
|
p,
|
|
@@ -546,7 +695,11 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
546
695
|
const tracked = trackingClient();
|
|
547
696
|
const mover = trackingMoveService();
|
|
548
697
|
const result = await applyOrganize(
|
|
549
|
-
{
|
|
698
|
+
{
|
|
699
|
+
client: tracked.client,
|
|
700
|
+
moveService: mover.moveService,
|
|
701
|
+
match: matchDeps(store),
|
|
702
|
+
},
|
|
550
703
|
ACCOUNT_CONFIG_ID,
|
|
551
704
|
matched,
|
|
552
705
|
p,
|
|
@@ -578,13 +731,21 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
578
731
|
const mover = trackingMoveService();
|
|
579
732
|
|
|
580
733
|
const first = await applyOrganize(
|
|
581
|
-
{
|
|
734
|
+
{
|
|
735
|
+
client: tracked.client,
|
|
736
|
+
moveService: mover.moveService,
|
|
737
|
+
match: matchDeps(store),
|
|
738
|
+
},
|
|
582
739
|
ACCOUNT_CONFIG_ID,
|
|
583
740
|
matched,
|
|
584
741
|
p,
|
|
585
742
|
);
|
|
586
743
|
const second = await applyOrganize(
|
|
587
|
-
{
|
|
744
|
+
{
|
|
745
|
+
client: tracked.client,
|
|
746
|
+
moveService: mover.moveService,
|
|
747
|
+
match: matchDeps(store),
|
|
748
|
+
},
|
|
588
749
|
ACCOUNT_CONFIG_ID,
|
|
589
750
|
matched,
|
|
590
751
|
p,
|
|
@@ -605,6 +766,154 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
605
766
|
});
|
|
606
767
|
});
|
|
607
768
|
|
|
769
|
+
describe("applyOrganize resolves move precedence against current Active filters (reader #350)", () => {
|
|
770
|
+
it("suppresses an out-ranked move but still applies the label", async () => {
|
|
771
|
+
const store = createMemoryVectorStore();
|
|
772
|
+
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
773
|
+
// The back-applied filter — "move to mbox-old" — is out-ranked by a
|
|
774
|
+
// more-recently-changed standing filter that currently claims msg-1 for a
|
|
775
|
+
// different destination.
|
|
776
|
+
const p = predicate({
|
|
777
|
+
actionLabelId: "lbl-1",
|
|
778
|
+
actionMailboxId: "mbox-old",
|
|
779
|
+
});
|
|
780
|
+
const newerFilter = filterItem({
|
|
781
|
+
filterId: "filter-newer",
|
|
782
|
+
ruleChangedAt: 1_000,
|
|
783
|
+
actionMailboxId: "mbox-new",
|
|
784
|
+
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const { messageIds: matched } = await matchOrganize(
|
|
788
|
+
matchDeps(store),
|
|
789
|
+
ACCOUNT_CONFIG_ID,
|
|
790
|
+
p,
|
|
791
|
+
);
|
|
792
|
+
const tracked = trackingClient({
|
|
793
|
+
activeFilters: [newerFilter],
|
|
794
|
+
threadMessages: { "msg-1": { subject: "Dinner reservation" } },
|
|
795
|
+
});
|
|
796
|
+
const mover = trackingMoveService();
|
|
797
|
+
const result = await applyOrganize(
|
|
798
|
+
{
|
|
799
|
+
client: tracked.client,
|
|
800
|
+
moveService: mover.moveService,
|
|
801
|
+
match: matchDeps(store),
|
|
802
|
+
},
|
|
803
|
+
ACCOUNT_CONFIG_ID,
|
|
804
|
+
matched,
|
|
805
|
+
p,
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
assert.equal(result.applied, 1, "a suppressed move is not a failure");
|
|
809
|
+
assert.equal(result.failed, 0);
|
|
810
|
+
assert.deepEqual(
|
|
811
|
+
tracked.labeled,
|
|
812
|
+
[{ messageId: "msg-1", labelId: "lbl-1" }],
|
|
813
|
+
"the additive label still applies even though the move is suppressed",
|
|
814
|
+
);
|
|
815
|
+
assert.deepEqual(
|
|
816
|
+
mover.moves,
|
|
817
|
+
[],
|
|
818
|
+
"the exclusive move is skipped in favor of the newer filter's own move",
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("moves the message when no other Active filter currently outranks it", async () => {
|
|
823
|
+
const store = createMemoryVectorStore();
|
|
824
|
+
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
825
|
+
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
826
|
+
// A different standing filter matches this message too, but agrees on the
|
|
827
|
+
// same destination — nothing to defer to.
|
|
828
|
+
const agreeingFilter = filterItem({
|
|
829
|
+
filterId: "filter-agrees",
|
|
830
|
+
ruleChangedAt: 1_000,
|
|
831
|
+
actionMailboxId: "mbox-target",
|
|
832
|
+
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
const { messageIds: matched } = await matchOrganize(
|
|
836
|
+
matchDeps(store),
|
|
837
|
+
ACCOUNT_CONFIG_ID,
|
|
838
|
+
p,
|
|
839
|
+
);
|
|
840
|
+
const tracked = trackingClient({
|
|
841
|
+
activeFilters: [agreeingFilter],
|
|
842
|
+
threadMessages: { "msg-1": { subject: "Dinner reservation" } },
|
|
843
|
+
});
|
|
844
|
+
const mover = trackingMoveService();
|
|
845
|
+
const result = await applyOrganize(
|
|
846
|
+
{
|
|
847
|
+
client: tracked.client,
|
|
848
|
+
moveService: mover.moveService,
|
|
849
|
+
match: matchDeps(store),
|
|
850
|
+
},
|
|
851
|
+
ACCOUNT_CONFIG_ID,
|
|
852
|
+
matched,
|
|
853
|
+
p,
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
assert.equal(result.applied, 1);
|
|
857
|
+
assert.equal(result.failed, 0);
|
|
858
|
+
assert.deepEqual(
|
|
859
|
+
mover.moves.map((m) => m.messageId),
|
|
860
|
+
["msg-1"],
|
|
861
|
+
"the move proceeds exactly as it would have before this check existed",
|
|
862
|
+
);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it("suppresses an out-ranked move by a newer *semantic* filter's persisted anchor", async () => {
|
|
866
|
+
const store = createMemoryVectorStore();
|
|
867
|
+
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
868
|
+
const p = predicate({ actionMailboxId: "mbox-old" });
|
|
869
|
+
const newerSemanticFilter = filterItem({
|
|
870
|
+
filterId: "filter-newer-semantic",
|
|
871
|
+
ruleChangedAt: 1_000,
|
|
872
|
+
actionMailboxId: "mbox-new",
|
|
873
|
+
hasAnchor: true,
|
|
874
|
+
});
|
|
875
|
+
const persistedAnchor: FilterAnchorItem = {
|
|
876
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
877
|
+
filterId: "filter-newer-semantic",
|
|
878
|
+
anchorEmbedding: ANCHOR_VECTOR,
|
|
879
|
+
anchorEmbeddingId: "test-model@4",
|
|
880
|
+
anchorSourceText: "book me a table",
|
|
881
|
+
anchorMessageId: "msg-anchor-2",
|
|
882
|
+
createdAt: 0,
|
|
883
|
+
updatedAt: 0,
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
const { messageIds: matched } = await matchOrganize(
|
|
887
|
+
matchDeps(store),
|
|
888
|
+
ACCOUNT_CONFIG_ID,
|
|
889
|
+
p,
|
|
890
|
+
);
|
|
891
|
+
const tracked = trackingClient({
|
|
892
|
+
activeFilters: [newerSemanticFilter],
|
|
893
|
+
filterAnchorRows: [persistedAnchor],
|
|
894
|
+
threadMessages: { "msg-1": { subject: "Dinner reservation" } },
|
|
895
|
+
});
|
|
896
|
+
const mover = trackingMoveService();
|
|
897
|
+
const result = await applyOrganize(
|
|
898
|
+
{
|
|
899
|
+
client: tracked.client,
|
|
900
|
+
moveService: mover.moveService,
|
|
901
|
+
match: matchDeps(store, [], [persistedAnchor]),
|
|
902
|
+
},
|
|
903
|
+
ACCOUNT_CONFIG_ID,
|
|
904
|
+
matched,
|
|
905
|
+
p,
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
assert.equal(result.applied, 1);
|
|
909
|
+
assert.deepEqual(
|
|
910
|
+
mover.moves,
|
|
911
|
+
[],
|
|
912
|
+
"a newer semantic filter's own persisted anchor outranks the move",
|
|
913
|
+
);
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
|
|
608
917
|
describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
609
918
|
const senderChunk = (
|
|
610
919
|
messageId: string,
|
|
@@ -702,7 +1011,7 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
|
702
1011
|
|
|
703
1012
|
const tracked = trackingClient();
|
|
704
1013
|
const result = await applyOrganize(
|
|
705
|
-
{ client: tracked.client },
|
|
1014
|
+
{ client: tracked.client, match: deps },
|
|
706
1015
|
ACCOUNT_CONFIG_ID,
|
|
707
1016
|
applied.messageIds,
|
|
708
1017
|
p,
|
package/src/service/organize.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
FilterItem,
|
|
3
|
+
IFilterAnchorRepository,
|
|
4
|
+
OrganizeJobRequestItem,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
7
|
+
import { FilterClauseField, FilterState } from "@remit/domain-enums";
|
|
3
8
|
import {
|
|
9
|
+
buildMatchText,
|
|
10
|
+
cosineSimilarity,
|
|
4
11
|
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
5
12
|
type FilterMessage,
|
|
6
13
|
literalClausesMatch,
|
|
7
14
|
NO_ACTION,
|
|
8
15
|
PlacementMoveService,
|
|
16
|
+
selectMoveWinner,
|
|
9
17
|
} from "@remit/mailbox-service";
|
|
10
18
|
import {
|
|
11
19
|
type AnchorPayload,
|
|
@@ -79,6 +87,15 @@ export interface OrganizeSemanticDeps {
|
|
|
79
87
|
anchorMessageId: string,
|
|
80
88
|
) => Promise<AnchorPayload | null>;
|
|
81
89
|
vectorStore: Pick<VectorStoreService, "query" | "getByMessage">;
|
|
90
|
+
/**
|
|
91
|
+
* Embed one candidate message's text — used only by the cross-filter
|
|
92
|
+
* precedence check ({@link findCurrentMoveWinner}) to compare a message
|
|
93
|
+
* against a *different* Active filter's persisted anchor, the same
|
|
94
|
+
* comparison `FilterPipeline.filterMatches` runs at index time. Never
|
|
95
|
+
* called by the anchor widen itself, which only ever runs a vector-store
|
|
96
|
+
* kNN read (see `semantic-capability.ts`).
|
|
97
|
+
*/
|
|
98
|
+
embed: (text: string) => Promise<number[]>;
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
/**
|
|
@@ -107,6 +124,17 @@ export interface OrganizeMatchDeps {
|
|
|
107
124
|
accountConfigId: string,
|
|
108
125
|
limit: number,
|
|
109
126
|
) => Promise<OrganizeCandidate[]>;
|
|
127
|
+
/**
|
|
128
|
+
* Every persisted FilterAnchor for the account — read-only, always
|
|
129
|
+
* available (never gated behind {@link semantic}, since it never touches
|
|
130
|
+
* the vector store). A back-apply predicate carries only a bare
|
|
131
|
+
* `anchorMessageId`, never a `filterId` (RFC 034 recap: this job is
|
|
132
|
+
* deliberately not a Filter), so this is how {@link matchSemantic} finds
|
|
133
|
+
* "the standing filter this anchor came from," if one still exists, to
|
|
134
|
+
* read its fixed-at-save-time vector instead of re-deriving one from the
|
|
135
|
+
* anchor message's current chunks (reader #350 / RFC 039 Decision 1).
|
|
136
|
+
*/
|
|
137
|
+
filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">;
|
|
110
138
|
}
|
|
111
139
|
|
|
112
140
|
/** The matched ids plus whether the semantic widen was skipped as unavailable. */
|
|
@@ -152,23 +180,61 @@ const filterMessageFromChunks = (
|
|
|
152
180
|
};
|
|
153
181
|
|
|
154
182
|
/**
|
|
155
|
-
* The
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* the
|
|
160
|
-
*
|
|
183
|
+
* The persisted FilterAnchor whose `anchorMessageId` matches this predicate's
|
|
184
|
+
* anchor, if the account has a standing filter built from that message (RFC
|
|
185
|
+
* 034 Decision 2 / RFC 039 Decision 1, reader #350). A back-apply predicate
|
|
186
|
+
* never carries a `filterId` — this job structurally is not a Filter — so the
|
|
187
|
+
* only way to recover "the filter this anchor came from" is by the value it
|
|
188
|
+
* was anchored on. The scan is over one account's (small, bounded) anchor
|
|
189
|
+
* rows, read-only, and never touches the vector store. Returns `undefined`
|
|
190
|
+
* when no standing filter was ever anchored on this message (or it has since
|
|
191
|
+
* been deleted), in which case the caller falls back to deriving the anchor
|
|
192
|
+
* live from the message's current chunk vectors, exactly as before.
|
|
193
|
+
*/
|
|
194
|
+
const findPersistedAnchor = async (
|
|
195
|
+
filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">,
|
|
196
|
+
accountConfigId: string,
|
|
197
|
+
anchorMessageId: string,
|
|
198
|
+
): Promise<AnchorPayload | undefined> => {
|
|
199
|
+
const anchors = await filterAnchors.listByAccountConfig(accountConfigId);
|
|
200
|
+
const persisted = anchors.find(
|
|
201
|
+
(anchor) => anchor.anchorMessageId === anchorMessageId,
|
|
202
|
+
);
|
|
203
|
+
if (!persisted) return undefined;
|
|
204
|
+
return {
|
|
205
|
+
anchorEmbedding: persisted.anchorEmbedding,
|
|
206
|
+
anchorEmbeddingId: persisted.anchorEmbeddingId,
|
|
207
|
+
anchorSourceText: persisted.anchorSourceText,
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The semantic (anchor) arm: read the anchor vector — the persisted
|
|
213
|
+
* `FilterAnchor` for a message a standing filter was built from (fixed at
|
|
214
|
+
* save time, unaffected by the anchor message's later deletion or
|
|
215
|
+
* re-chunking), or else pool it fresh from the anchor message's existing
|
|
216
|
+
* chunk vectors, the same as before this filter existed or ever had one.
|
|
217
|
+
* Fan out with a k-NN query gated on the cosine threshold, then refine by
|
|
218
|
+
* literal clauses reconstructed from the same chunk vectors. Every read here
|
|
219
|
+
* goes through the vector store; a deployment without the vector pipeline
|
|
220
|
+
* fails on the first call, which {@link matchOrganize} catches. Returns null
|
|
221
|
+
* when neither a persisted anchor nor the message's own chunk vectors exist
|
|
222
|
+
* to pool.
|
|
161
223
|
*/
|
|
162
224
|
const matchSemantic = async (
|
|
163
|
-
|
|
225
|
+
deps: OrganizeMatchDeps,
|
|
164
226
|
accountConfigId: string,
|
|
165
227
|
predicate: OrganizePredicate,
|
|
166
228
|
limit: number,
|
|
167
229
|
): Promise<string[] | null> => {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
230
|
+
const semantic = deps.semantic();
|
|
231
|
+
const anchor =
|
|
232
|
+
(await findPersistedAnchor(
|
|
233
|
+
deps.filterAnchors,
|
|
234
|
+
accountConfigId,
|
|
235
|
+
predicate.anchorMessageId,
|
|
236
|
+
)) ??
|
|
237
|
+
(await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId));
|
|
172
238
|
if (!anchor) return null;
|
|
173
239
|
const threshold =
|
|
174
240
|
predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
|
|
@@ -302,7 +368,7 @@ export const matchOrganize = async (
|
|
|
302
368
|
|
|
303
369
|
try {
|
|
304
370
|
const semanticIds = await matchSemantic(
|
|
305
|
-
deps
|
|
371
|
+
deps,
|
|
306
372
|
accountConfigId,
|
|
307
373
|
predicate,
|
|
308
374
|
limit,
|
|
@@ -343,6 +409,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
|
343
409
|
buildAnchor: (accountConfigId, anchorMessageId) =>
|
|
344
410
|
buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
|
|
345
411
|
vectorStore: store,
|
|
412
|
+
embed: async (text) => (await embedder.embed([text]))[0],
|
|
346
413
|
};
|
|
347
414
|
return cachedSemantic;
|
|
348
415
|
};
|
|
@@ -404,6 +471,7 @@ export const buildOrganizeMatchDeps = (
|
|
|
404
471
|
): OrganizeMatchDeps => ({
|
|
405
472
|
semantic: buildSemanticFromEnv,
|
|
406
473
|
listAccountFilterMessages: listAccountFilterMessagesFromClient(client),
|
|
474
|
+
filterAnchors: client.filterAnchor,
|
|
407
475
|
});
|
|
408
476
|
|
|
409
477
|
/**
|
|
@@ -433,6 +501,13 @@ export const buildOrganizeMoveService = (
|
|
|
433
501
|
export interface ApplyOrganizeDeps {
|
|
434
502
|
client: RemitClient;
|
|
435
503
|
moveService?: PlacementMoveService;
|
|
504
|
+
/**
|
|
505
|
+
* The same matcher deps {@link matchOrganize} uses — reused here only for
|
|
506
|
+
* their vector-store/embedder access, to compare a candidate message
|
|
507
|
+
* against a *different* filter's persisted anchor when checking exclusive-
|
|
508
|
+
* move precedence (RFC 039 Decision 2, reader #350).
|
|
509
|
+
*/
|
|
510
|
+
match: OrganizeMatchDeps;
|
|
436
511
|
}
|
|
437
512
|
|
|
438
513
|
export interface ApplyOrganizeResult {
|
|
@@ -440,6 +515,160 @@ export interface ApplyOrganizeResult {
|
|
|
440
515
|
failed: number;
|
|
441
516
|
}
|
|
442
517
|
|
|
518
|
+
/**
|
|
519
|
+
* The vector-free literal-match projection of one already-stored message,
|
|
520
|
+
* read from its ThreadMessage row — the same fields and the same fidelity
|
|
521
|
+
* tradeoff {@link listAccountFilterMessagesFromClient} accepts for the
|
|
522
|
+
* back-apply predicate's own literal clauses (full-fidelity From/Subject/
|
|
523
|
+
* ListId, empty body text, so a `HasWords` clause on a *different* filter
|
|
524
|
+
* cannot be proven to currently match here). `undefined` when the message no
|
|
525
|
+
* longer exists.
|
|
526
|
+
*/
|
|
527
|
+
const findFilterMessageForPrecedence = async (
|
|
528
|
+
client: Pick<RemitClient, "threadMessage">,
|
|
529
|
+
accountConfigId: string,
|
|
530
|
+
messageId: string,
|
|
531
|
+
): Promise<FilterMessage | undefined> => {
|
|
532
|
+
const row = await client.threadMessage
|
|
533
|
+
.get(accountConfigId, messageId)
|
|
534
|
+
.catch((error: unknown) => {
|
|
535
|
+
if (error instanceof NotFoundError) return undefined;
|
|
536
|
+
throw error;
|
|
537
|
+
});
|
|
538
|
+
if (!row) return undefined;
|
|
539
|
+
return {
|
|
540
|
+
from: row.fromEmail ?? "",
|
|
541
|
+
fromName: row.fromName ?? "",
|
|
542
|
+
subject: row.subject ?? "",
|
|
543
|
+
text: "",
|
|
544
|
+
listId: row.listId ?? "",
|
|
545
|
+
};
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Whether one *other* Active filter with a move action currently matches this
|
|
550
|
+
* message — mirrors `FilterPipeline.filterMatches` (mailbox-service
|
|
551
|
+
* filters/pipeline.ts) exactly: literal clauses first, then, for a filter
|
|
552
|
+
* with a semantic anchor, its own persisted `FilterAnchor` compared against
|
|
553
|
+
* the candidate's embedding. A stale/incompatible anchor on the *other*
|
|
554
|
+
* filter is isolated to that filter (skipped, not thrown) — the same
|
|
555
|
+
* resilience `filterMatches` gives index-time matching, so one bad anchor
|
|
556
|
+
* elsewhere never breaks this back-apply's move.
|
|
557
|
+
*/
|
|
558
|
+
const filterCurrentlyMatches = async (
|
|
559
|
+
filterAnchorService: Pick<IFilterAnchorRepository, "get">,
|
|
560
|
+
accountConfigId: string,
|
|
561
|
+
filter: FilterItem,
|
|
562
|
+
msg: FilterMessage,
|
|
563
|
+
embed: () => Promise<number[] | null>,
|
|
564
|
+
): Promise<boolean> => {
|
|
565
|
+
if (!literalClausesMatch(filter.literalClauses, filter.matchOperator, msg)) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
if (!filter.hasAnchor) {
|
|
569
|
+
return filter.literalClauses.length > 0;
|
|
570
|
+
}
|
|
571
|
+
const anchor = await filterAnchorService.get(
|
|
572
|
+
accountConfigId,
|
|
573
|
+
filter.filterId,
|
|
574
|
+
);
|
|
575
|
+
if (!anchor) return false;
|
|
576
|
+
const vector = await embed();
|
|
577
|
+
if (!vector) return false;
|
|
578
|
+
try {
|
|
579
|
+
return (
|
|
580
|
+
cosineSimilarity(vector, anchor.anchorEmbedding) >=
|
|
581
|
+
DEFAULT_SEMANTIC_MATCH_THRESHOLD
|
|
582
|
+
);
|
|
583
|
+
} catch {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The filter that currently wins this message's exclusive move, evaluated
|
|
590
|
+
* fresh against every Active filter with a move action — the same
|
|
591
|
+
* cross-filter arbitration index-time matching runs
|
|
592
|
+
* (`FilterPipeline`/`selectMoveWinner`), never the back-apply job's own
|
|
593
|
+
* snapshotted predicate (RFC 039 Decision 2, reader #350). This is what lets
|
|
594
|
+
* back-apply defer to a filter created or edited *after* the job was
|
|
595
|
+
* requested. `movers` is fetched once per {@link applyOrganize} call, not
|
|
596
|
+
* once per message — a back-apply pass runs in one short burst, so the
|
|
597
|
+
* account's Active filter set does not need re-reading per message.
|
|
598
|
+
*
|
|
599
|
+
* Returns `undefined` — meaning "nothing else contests this move" — whenever
|
|
600
|
+
* there are no other Active mover filters, or the message's own projection
|
|
601
|
+
* cannot be built (deleted between match and apply). Both are the common
|
|
602
|
+
* case and the safe default: proceed with the requested move exactly as
|
|
603
|
+
* before this check existed.
|
|
604
|
+
*/
|
|
605
|
+
const findCurrentMoveWinner = async (
|
|
606
|
+
deps: {
|
|
607
|
+
client: Pick<RemitClient, "threadMessage" | "filterAnchor">;
|
|
608
|
+
match: OrganizeMatchDeps;
|
|
609
|
+
},
|
|
610
|
+
movers: readonly FilterItem[],
|
|
611
|
+
accountConfigId: string,
|
|
612
|
+
messageId: string,
|
|
613
|
+
): Promise<FilterItem | undefined> => {
|
|
614
|
+
if (movers.length === 0) return undefined;
|
|
615
|
+
const msg = await findFilterMessageForPrecedence(
|
|
616
|
+
deps.client,
|
|
617
|
+
accountConfigId,
|
|
618
|
+
messageId,
|
|
619
|
+
);
|
|
620
|
+
if (!msg) return undefined;
|
|
621
|
+
|
|
622
|
+
let messageEmbedding: number[] | null | undefined;
|
|
623
|
+
const embed = async (): Promise<number[] | null> => {
|
|
624
|
+
if (messageEmbedding !== undefined) return messageEmbedding;
|
|
625
|
+
try {
|
|
626
|
+
messageEmbedding = await deps.match.semantic().embed(buildMatchText(msg));
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (!noteSemanticCapabilityAbsence(error)) throw error;
|
|
629
|
+
messageEmbedding = null;
|
|
630
|
+
}
|
|
631
|
+
return messageEmbedding;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const matched: FilterItem[] = [];
|
|
635
|
+
for (const filter of movers) {
|
|
636
|
+
const isMatch = await filterCurrentlyMatches(
|
|
637
|
+
deps.client.filterAnchor,
|
|
638
|
+
accountConfigId,
|
|
639
|
+
filter,
|
|
640
|
+
msg,
|
|
641
|
+
embed,
|
|
642
|
+
);
|
|
643
|
+
if (isMatch) matched.push(filter);
|
|
644
|
+
}
|
|
645
|
+
return selectMoveWinner(matched);
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Every currently-Active filter with a move action, its lazy Temporary-expiry
|
|
650
|
+
* check already applied — the fixed candidate set {@link findCurrentMoveWinner}
|
|
651
|
+
* arbitrates against for every message in this pass. Empty (and read exactly
|
|
652
|
+
* once) when no move action was requested, so a label-only back-apply never
|
|
653
|
+
* pays for this at all.
|
|
654
|
+
*/
|
|
655
|
+
const listCurrentMovers = async (
|
|
656
|
+
client: Pick<RemitClient, "filter">,
|
|
657
|
+
accountConfigId: string,
|
|
658
|
+
): Promise<FilterItem[]> => {
|
|
659
|
+
const active = await client.filter.listByAccountAndState(
|
|
660
|
+
accountConfigId,
|
|
661
|
+
FilterState.Active,
|
|
662
|
+
);
|
|
663
|
+
const movers: FilterItem[] = [];
|
|
664
|
+
for (const filter of active) {
|
|
665
|
+
if (filter.actionMailboxId === NO_ACTION) continue;
|
|
666
|
+
const usable = await client.filter.refreshExpiry(filter);
|
|
667
|
+
if (usable.state === FilterState.Active) movers.push(usable);
|
|
668
|
+
}
|
|
669
|
+
return movers;
|
|
670
|
+
};
|
|
671
|
+
|
|
443
672
|
/**
|
|
444
673
|
* Apply the back-apply action to every matched message, reusing the index-time
|
|
445
674
|
* apply plumbing: an idempotent MessageLabel upsert (additive) with
|
|
@@ -447,6 +676,13 @@ export interface ApplyOrganizeResult {
|
|
|
447
676
|
* exactly like a hand-applied label (RFC 034 Decision 3.3) — and an idempotent
|
|
448
677
|
* folder move (exclusive). One poisoned message never fails the batch; it is
|
|
449
678
|
* counted as failed and the pass continues.
|
|
679
|
+
*
|
|
680
|
+
* Before an exclusive move, resolves whether a *different* Active filter
|
|
681
|
+
* currently wins that message (RFC 039 Decision 2, reader #350): if so, the
|
|
682
|
+
* move is skipped for that message — the label action above, if requested,
|
|
683
|
+
* still applies, since labels are additive and always safe. A message with no
|
|
684
|
+
* contesting filter, or whose own back-applied filter is still the current
|
|
685
|
+
* winner, moves exactly as before this check existed.
|
|
450
686
|
*/
|
|
451
687
|
export const applyOrganize = async (
|
|
452
688
|
deps: ApplyOrganizeDeps,
|
|
@@ -454,12 +690,16 @@ export const applyOrganize = async (
|
|
|
454
690
|
messageIds: readonly string[],
|
|
455
691
|
predicate: OrganizePredicate,
|
|
456
692
|
): Promise<ApplyOrganizeResult> => {
|
|
457
|
-
const { client, moveService } = deps;
|
|
693
|
+
const { client, moveService, match } = deps;
|
|
458
694
|
const applyLabel =
|
|
459
695
|
predicate.actionLabelId !== NO_ACTION && predicate.actionLabelId !== "";
|
|
460
696
|
const applyMove =
|
|
461
697
|
predicate.actionMailboxId !== NO_ACTION && predicate.actionMailboxId !== "";
|
|
462
698
|
|
|
699
|
+
const movers = applyMove
|
|
700
|
+
? await listCurrentMovers(client, accountConfigId)
|
|
701
|
+
: [];
|
|
702
|
+
|
|
463
703
|
const applyToMessage = async (messageId: string): Promise<void> => {
|
|
464
704
|
if (applyLabel) {
|
|
465
705
|
await client.messageLabel.apply({
|
|
@@ -469,6 +709,18 @@ export const applyOrganize = async (
|
|
|
469
709
|
});
|
|
470
710
|
}
|
|
471
711
|
if (applyMove) {
|
|
712
|
+
const winner = await findCurrentMoveWinner(
|
|
713
|
+
{ client, match },
|
|
714
|
+
movers,
|
|
715
|
+
accountConfigId,
|
|
716
|
+
messageId,
|
|
717
|
+
);
|
|
718
|
+
if (winner && winner.actionMailboxId !== predicate.actionMailboxId) {
|
|
719
|
+
// A more-recently-changed filter currently claims this message's
|
|
720
|
+
// move (RFC 034 Decision 3.2) — defer to it. The label above, if
|
|
721
|
+
// requested, has already applied; only the move is suppressed.
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
472
724
|
if (!moveService) {
|
|
473
725
|
// An exclusive move was requested but this caller wired no move
|
|
474
726
|
// service. Never silently pretend it applied — surface it as a
|