@remit/backend 0.0.142 → 0.0.144
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/dist/dev-server/content-handler.d.ts +38 -0
- package/dist/dev-server/content-handler.js +46 -0
- package/dist/src/api.d.ts +7 -0
- package/dist/src/api.js +144 -0
- package/dist/src/auth.d.ts +30 -0
- package/dist/src/auth.js +73 -0
- package/dist/src/config/msoauth.d.ts +30 -0
- package/dist/src/config/msoauth.js +30 -0
- package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
- package/dist/src/data-backend.js +12 -0
- package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
- package/dist/src/derive/autoMoved.js +49 -0
- package/dist/src/derive/contentSignature.d.ts +41 -0
- package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
- package/dist/src/derive/contentUrl.d.ts +46 -0
- package/dist/src/derive/contentUrl.js +46 -0
- package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
- package/dist/src/derive/deriveMuted.js +7 -0
- package/dist/src/derive/enrichThreadRows.d.ts +118 -0
- package/dist/src/derive/enrichThreadRows.js +151 -0
- package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
- package/dist/src/derive/filterThreadCriteria.js +24 -0
- package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
- package/dist/src/derive/pendingMoveCounts.js +53 -0
- package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
- package/dist/src/derive/senderTrust.js +19 -0
- package/dist/src/error.d.ts +21 -0
- package/dist/src/error.js +85 -0
- package/dist/src/handlers/account-guards.d.ts +36 -0
- package/dist/src/handlers/account-guards.js +107 -0
- package/dist/src/handlers/account-oauth.d.ts +14 -0
- package/dist/src/handlers/account-oauth.js +387 -0
- package/dist/src/handlers/account-overrides.d.ts +53 -0
- package/dist/src/handlers/account-overrides.js +233 -0
- package/dist/src/handlers/account-signature.d.ts +28 -0
- package/dist/src/handlers/account-signature.js +85 -0
- package/dist/src/handlers/account.d.ts +6 -0
- package/dist/src/handlers/account.js +350 -0
- package/dist/src/handlers/address.d.ts +35 -0
- package/dist/src/handlers/address.js +141 -0
- package/dist/src/handlers/calendar-event.d.ts +70 -0
- package/dist/src/handlers/calendar-event.js +425 -0
- package/dist/src/handlers/calendar-feed.d.ts +47 -0
- package/dist/src/handlers/calendar-feed.js +192 -0
- package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
- package/dist/src/handlers/calendar-suggestion.js +212 -0
- package/dist/src/handlers/calendar.d.ts +116 -0
- package/dist/src/handlers/calendar.js +330 -0
- package/dist/src/handlers/config.d.ts +43 -0
- package/dist/src/handlers/config.js +176 -0
- package/dist/src/handlers/ensure-account-config.d.ts +9 -0
- package/dist/src/handlers/ensure-account-config.js +27 -0
- package/dist/src/handlers/filter.d.ts +3 -0
- package/dist/src/handlers/filter.js +103 -0
- package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
- package/dist/src/handlers/folder-role-appointments.js +168 -0
- package/dist/src/handlers/folder-role-labels.d.ts +26 -0
- package/dist/src/handlers/folder-role-labels.js +40 -0
- package/dist/src/handlers/folder-role.d.ts +9 -0
- package/dist/src/handlers/folder-role.js +40 -0
- package/dist/src/handlers/index.d.ts +2 -0
- package/dist/src/handlers/index.js +60 -0
- package/dist/src/handlers/label.d.ts +43 -0
- package/dist/src/handlers/label.js +114 -0
- package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
- package/dist/src/handlers/mailbox-bindings.js +61 -0
- package/dist/src/handlers/mailbox.d.ts +23 -0
- package/dist/src/handlers/mailbox.js +219 -0
- package/dist/src/handlers/me.d.ts +2 -0
- package/dist/src/handlers/me.js +138 -0
- package/dist/src/handlers/message.d.ts +84 -0
- package/dist/src/handlers/message.js +607 -0
- package/dist/src/handlers/organize.d.ts +10 -0
- package/dist/src/handlers/organize.js +112 -0
- package/dist/src/handlers/outbox-attachment.d.ts +23 -0
- package/dist/src/handlers/outbox-attachment.js +81 -0
- package/dist/src/handlers/outbox.d.ts +4 -0
- package/dist/src/handlers/outbox.js +144 -0
- package/dist/src/handlers/sync-progress.d.ts +46 -0
- package/dist/src/handlers/sync-progress.js +63 -0
- package/dist/src/handlers/sync.d.ts +28 -0
- package/dist/src/handlers/sync.js +82 -0
- package/dist/src/handlers/system-update.d.ts +2 -0
- package/dist/src/handlers/system-update.js +172 -0
- package/dist/src/handlers/thread.d.ts +148 -0
- package/dist/src/handlers/thread.js +233 -0
- package/dist/src/handlers/unified-threads.d.ts +247 -0
- package/dist/src/handlers/unified-threads.js +409 -0
- package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
- package/dist/src/handlers/vip-suggestions.js +16 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +37 -0
- package/{src/json.ts → dist/src/json.d.ts} +1 -4
- package/dist/src/json.js +10 -0
- package/dist/src/jwt-auth.d.ts +18 -0
- package/dist/src/jwt-auth.js +97 -0
- package/dist/src/request-context.d.ts +9 -0
- package/dist/src/request-context.js +22 -0
- package/dist/src/request.d.ts +3 -0
- package/dist/src/request.js +7 -0
- package/dist/src/response.d.ts +27 -0
- package/dist/src/response.js +87 -0
- package/dist/src/service/account-deletion.d.ts +22 -0
- package/dist/src/service/account-deletion.js +39 -0
- package/dist/src/service/compose-sqlite.d.ts +2 -0
- package/dist/src/service/compose-sqlite.js +11 -0
- package/dist/src/service/create-remit-client.d.ts +95 -0
- package/dist/src/service/create-remit-client.js +207 -0
- package/dist/src/service/data-client.d.ts +12 -0
- package/dist/src/service/data-client.js +44 -0
- package/dist/src/service/export-identity.d.ts +3 -0
- package/dist/src/service/export-identity.js +35 -0
- package/dist/src/service/fire-and-forget.d.ts +54 -0
- package/dist/src/service/fire-and-forget.js +38 -0
- package/dist/src/service/organize.d.ts +184 -0
- package/dist/src/service/organize.js +384 -0
- package/dist/src/service/refusal.d.ts +4 -0
- package/dist/src/service/refusal.js +20 -0
- package/dist/src/service/sender-category.d.ts +51 -0
- package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
- package/dist/src/service/sqs.d.ts +1 -0
- package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
- package/dist/src/service/trigger-sync.d.ts +51 -0
- package/dist/src/service/trigger-sync.js +39 -0
- package/dist/src/types.d.ts +49 -0
- package/dist/src/types.js +1 -0
- package/package.json +113 -98
- package/.env.test +0 -4
- package/dev-server/content-auth.test.ts +0 -113
- package/dev-server/content-auth.ts +0 -54
- package/dev-server/content-handler.test.ts +0 -105
- package/dev-server/content-handler.ts +0 -79
- package/dev-server/content-path.test.ts +0 -37
- package/dev-server/content-path.ts +0 -27
- package/dev-server/cors.test.ts +0 -48
- package/dev-server/cors.ts +0 -25
- package/dev-server/keep-alive.test.ts +0 -24
- package/dev-server/lambda-helpers.ts +0 -69
- package/dev-server/log-output.test.ts +0 -173
- package/dev-server/oauth-callback-route.test.ts +0 -66
- package/dev-server/relational-health.test.ts +0 -83
- package/dev-server/relational-health.ts +0 -39
- package/dev-server/server.ts +0 -384
- package/dev-server/sync-age.test.ts +0 -91
- package/dev-server/sync-age.ts +0 -55
- package/dev-server/upload-handler.ts +0 -132
- package/dev-server/upload-route.test.ts +0 -196
- package/scripts/backfill-classification.ts +0 -91
- package/scripts/backfill-list-id.ts +0 -83
- package/scripts/config-save.ts +0 -128
- package/src/auth.test.ts +0 -12
- package/src/auth.ts +0 -92
- package/src/config/msoauth.test.ts +0 -48
- package/src/config/msoauth.ts +0 -48
- package/src/data-backend.test.ts +0 -23
- package/src/derive/autoMoved.test.ts +0 -104
- package/src/derive/contentSignature.test.ts +0 -161
- package/src/derive/contentUrl.test.ts +0 -172
- package/src/derive/contentUrl.ts +0 -86
- package/src/derive/deriveMuted.test.ts +0 -34
- package/src/derive/enrichThreadRows.test.ts +0 -360
- package/src/derive/enrichThreadRows.ts +0 -234
- package/src/derive/filterThreadCriteria.test.ts +0 -126
- package/src/derive/filterThreadCriteria.ts +0 -51
- package/src/derive/senderTrust.test.ts +0 -67
- package/src/error.test.ts +0 -201
- package/src/error.ts +0 -123
- package/src/handlers/account-guards.test.ts +0 -67
- package/src/handlers/account-guards.ts +0 -169
- package/src/handlers/account-oauth-callback.test.ts +0 -155
- package/src/handlers/account-oauth-consent.test.ts +0 -336
- package/src/handlers/account-oauth-start.test.ts +0 -191
- package/src/handlers/account-oauth.test.ts +0 -439
- package/src/handlers/account-oauth.ts +0 -635
- package/src/handlers/account-overrides.test.ts +0 -88
- package/src/handlers/account-overrides.ts +0 -323
- package/src/handlers/account-ownership.ts +0 -25
- package/src/handlers/account-signature.ts +0 -117
- package/src/handlers/account-synced-services.test.ts +0 -245
- package/src/handlers/account.ts +0 -552
- package/src/handlers/address.test.ts +0 -381
- package/src/handlers/address.ts +0 -200
- package/src/handlers/calendar-event.test.ts +0 -685
- package/src/handlers/calendar-event.ts +0 -738
- package/src/handlers/calendar-feed-validators.test.ts +0 -228
- package/src/handlers/calendar-feed.test.ts +0 -439
- package/src/handlers/calendar-feed.ts +0 -291
- package/src/handlers/calendar-sqlite-fixture.ts +0 -36
- package/src/handlers/calendar-suggestion.test.ts +0 -1269
- package/src/handlers/calendar-suggestion.ts +0 -425
- package/src/handlers/calendar.test.ts +0 -1080
- package/src/handlers/calendar.ts +0 -517
- package/src/handlers/config.test.ts +0 -245
- package/src/handlers/config.ts +0 -332
- package/src/handlers/ensure-account-config.ts +0 -30
- package/src/handlers/filter.test.ts +0 -644
- package/src/handlers/filter.ts +0 -518
- package/src/handlers/folder-role-appointments.test.ts +0 -438
- package/src/handlers/folder-role-appointments.ts +0 -249
- package/src/handlers/folder-role-labels.ts +0 -63
- package/src/handlers/folder-role.test.ts +0 -209
- package/src/handlers/folder-role.ts +0 -80
- package/src/handlers/index.ts +0 -83
- package/src/handlers/label.test.ts +0 -112
- package/src/handlers/label.ts +0 -210
- package/src/handlers/mailbox-bindings.ts +0 -111
- package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
- package/src/handlers/mailbox-settled-target.test.ts +0 -251
- package/src/handlers/mailbox.test.ts +0 -280
- package/src/handlers/mailbox.ts +0 -581
- package/src/handlers/me.test.ts +0 -81
- package/src/handlers/me.ts +0 -203
- package/src/handlers/message-read-backfill.test.ts +0 -103
- package/src/handlers/message.test.ts +0 -131
- package/src/handlers/message.ts +0 -1131
- package/src/handlers/organize.test.ts +0 -277
- package/src/handlers/organize.ts +0 -197
- package/src/handlers/outbox-attachment.test.ts +0 -765
- package/src/handlers/outbox-attachment.ts +0 -126
- package/src/handlers/outbox-list.test.ts +0 -179
- package/src/handlers/outbox.test.ts +0 -772
- package/src/handlers/outbox.ts +0 -258
- package/src/handlers/search.test.ts +0 -161
- package/src/handlers/search.ts +0 -105
- package/src/handlers/sync-progress.test.ts +0 -205
- package/src/handlers/sync-progress.ts +0 -99
- package/src/handlers/sync.test.ts +0 -168
- package/src/handlers/sync.ts +0 -118
- package/src/handlers/system-update.test.ts +0 -497
- package/src/handlers/system-update.ts +0 -230
- package/src/handlers/thread.test.ts +0 -396
- package/src/handlers/thread.ts +0 -427
- package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
- package/src/handlers/unified-threads.test.ts +0 -785
- package/src/handlers/unified-threads.ts +0 -669
- package/src/index.test.ts +0 -83
- package/src/index.ts +0 -228
- package/src/jwt-auth.test.ts +0 -152
- package/src/jwt-auth.ts +0 -132
- package/src/log-redaction.test.ts +0 -144
- package/src/openapi-spec.test.ts +0 -118
- package/src/request-context.test.ts +0 -61
- package/src/request-context.ts +0 -33
- package/src/request-pipeline.test.ts +0 -88
- package/src/request.ts +0 -10
- package/src/response.test.ts +0 -130
- package/src/response.ts +0 -144
- package/src/service/compose-sqlite.ts +0 -98
- package/src/service/config-import.ts +0 -39
- package/src/service/create-remit-client.ts +0 -521
- package/src/service/data-client.test.ts +0 -97
- package/src/service/data-client.ts +0 -58
- package/src/service/export-identity.ts +0 -39
- package/src/service/filter.ts +0 -40
- package/src/service/fire-and-forget.test.ts +0 -126
- package/src/service/fire-and-forget.ts +0 -79
- package/src/service/localhost-env-config.test.ts +0 -36
- package/src/service/organize-queue.ts +0 -98
- package/src/service/organize.test.ts +0 -1451
- package/src/service/organize.ts +0 -1025
- package/src/service/read-path-filter-config.test.ts +0 -243
- package/src/service/semantic-capability.test.ts +0 -78
- package/src/service/semantic-capability.ts +0 -95
- package/src/service/sender-category.test.ts +0 -235
- package/src/service/trigger-sync.test.ts +0 -205
- package/src/service/trigger-sync.ts +0 -100
- package/src/stdout-capture-fixture.ts +0 -40
- package/src/types.ts +0 -271
- package/tsconfig.json +0 -7
package/src/service/organize.ts
DELETED
|
@@ -1,1025 +0,0 @@
|
|
|
1
|
-
import { inspect } from "node:util";
|
|
2
|
-
import type {
|
|
3
|
-
FilterAnchorItem,
|
|
4
|
-
FilterItem,
|
|
5
|
-
IFilterAnchorRepository,
|
|
6
|
-
OrganizeJobRequestItem,
|
|
7
|
-
} from "@remit/data-ports";
|
|
8
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
9
|
-
import { FilterClauseField, FilterState } from "@remit/domain-enums";
|
|
10
|
-
import { logger } from "@remit/logger-lambda";
|
|
11
|
-
import {
|
|
12
|
-
DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
13
|
-
type FilterMessage,
|
|
14
|
-
FilterPipeline,
|
|
15
|
-
type LiteralClauseNarrowing,
|
|
16
|
-
literalClausesMatch,
|
|
17
|
-
literalClauseTerms,
|
|
18
|
-
type MessageEmbedder,
|
|
19
|
-
NO_ACTION,
|
|
20
|
-
PlacementMoveService,
|
|
21
|
-
refreshAnchorForEmbedder,
|
|
22
|
-
} from "@remit/mailbox-service";
|
|
23
|
-
import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
|
|
24
|
-
import {
|
|
25
|
-
type AnchorPayload,
|
|
26
|
-
buildMessageAnchor,
|
|
27
|
-
type VectorRecord,
|
|
28
|
-
type VectorStoreService,
|
|
29
|
-
} from "@remit/search-service";
|
|
30
|
-
import {
|
|
31
|
-
buildEmbeddingServiceFromEnv,
|
|
32
|
-
buildVectorStoreFromEnv,
|
|
33
|
-
} from "@remit/search-service/from-env";
|
|
34
|
-
import type { RemitClient } from "./data-client.js";
|
|
35
|
-
import {
|
|
36
|
-
isSemanticCapabilityAbsence,
|
|
37
|
-
noteSemanticCapabilityAbsence,
|
|
38
|
-
} from "./semantic-capability.js";
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Hard cap on both the previewed and the applied set. A back-apply is a
|
|
42
|
-
* data-heavy corpus pass (remit-data-heavy: frugal): the semantic side is
|
|
43
|
-
* bounded by the vector query's `topK`, the literal-only side by counting
|
|
44
|
-
* MATCHES as it pages a store-side narrowed listing, and the final match set
|
|
45
|
-
* never exceeds this. It bounds the answer, never the corpus the question is
|
|
46
|
-
* asked of (#459).
|
|
47
|
-
*/
|
|
48
|
-
export const ORGANIZE_MATCH_LIMIT = 500;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* How many chunk matches to pull per requested message. A message contributes
|
|
52
|
-
* several chunk vectors (sender / subject / body / …); over-fetching chunks
|
|
53
|
-
* keeps enough distinct messages after de-duplication to fill the cap.
|
|
54
|
-
*/
|
|
55
|
-
const VECTOR_CHUNK_FACTOR = 8;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The predicate + action for one back-apply pass — the OrganizeInput fields,
|
|
59
|
-
* flattened. `anchorMessageId` uses the `"None"` sentinel (never optional) to
|
|
60
|
-
* mean "purely literal", matching how the job row and the action fields carry
|
|
61
|
-
* absence (RFC 034 Decision 3.1).
|
|
62
|
-
*/
|
|
63
|
-
export interface OrganizePredicate {
|
|
64
|
-
anchorMessageId: string;
|
|
65
|
-
matchOperator: FilterItem["matchOperator"];
|
|
66
|
-
literalClauses: FilterItem["literalClauses"];
|
|
67
|
-
similarityThreshold: number;
|
|
68
|
-
actionLabelId: string;
|
|
69
|
-
actionMailboxId: string;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The predicate a back-apply job snapshotted onto its row, read back verbatim so
|
|
74
|
-
* the worker runs exactly what the request asked for.
|
|
75
|
-
*/
|
|
76
|
-
export const predicateFromJob = (
|
|
77
|
-
job: OrganizeJobRequestItem,
|
|
78
|
-
): OrganizePredicate => ({
|
|
79
|
-
anchorMessageId: job.anchorMessageId,
|
|
80
|
-
matchOperator: job.matchOperator,
|
|
81
|
-
literalClauses: job.literalClauses,
|
|
82
|
-
similarityThreshold: job.similarityThreshold,
|
|
83
|
-
actionLabelId: job.actionLabelId,
|
|
84
|
-
actionMailboxId: job.actionMailboxId,
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The vector-backed semantic side of the matcher — the only part that reaches
|
|
89
|
-
* `sqlite-vec`/`@huggingface/transformers`. Constructed lazily (see
|
|
90
|
-
* {@link OrganizeMatchDeps.semantic}) so a literal-only predicate never touches
|
|
91
|
-
* the vector extension, and so a deployment that ships no vector pipeline only
|
|
92
|
-
* fails on the import when a predicate actually asks to widen — where the
|
|
93
|
-
* failure is caught and degraded rather than surfaced as a 500 (#226/#201).
|
|
94
|
-
*/
|
|
95
|
-
export interface OrganizeSemanticDeps {
|
|
96
|
-
buildAnchor: (
|
|
97
|
-
accountConfigId: string,
|
|
98
|
-
anchorMessageId: string,
|
|
99
|
-
) => Promise<AnchorPayload | null>;
|
|
100
|
-
vectorStore: Pick<VectorStoreService, "query" | "getByMessage">;
|
|
101
|
-
/**
|
|
102
|
-
* Embed one text — the `AnchorEmbedder` half of these deps, used to
|
|
103
|
-
* re-embed a drifted persisted anchor in place ({@link
|
|
104
|
-
* refreshAnchorForEmbedder}). Never called by the anchor widen itself,
|
|
105
|
-
* which only ever runs a vector-store kNN read (see
|
|
106
|
-
* `semantic-capability.ts`).
|
|
107
|
-
*/
|
|
108
|
-
embed: (text: string) => Promise<number[]>;
|
|
109
|
-
/**
|
|
110
|
-
* The configured model's `<modelId>@<dimensions>` id, compared against a
|
|
111
|
-
* persisted anchor's `anchorEmbeddingId` to catch a same-dimension model
|
|
112
|
-
* swap that would otherwise score a current-model vector against a
|
|
113
|
-
* stale-model anchor (RFC 039 Decision 1a, reader #399).
|
|
114
|
-
*/
|
|
115
|
-
readonly embeddingId: string;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* One corpus message projected onto the fields a literal clause matches
|
|
120
|
-
* against, paired with its id. Sourced from the core message rows, never the
|
|
121
|
-
* vector store, so literal matching runs on any deployment.
|
|
122
|
-
*/
|
|
123
|
-
export interface OrganizeCandidate {
|
|
124
|
-
messageId: string;
|
|
125
|
-
message: FilterMessage;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* One page of the narrowed corpus, cursored exactly as the underlying listing
|
|
130
|
-
* is: an absent `continuationToken` means the store has no more rows matching
|
|
131
|
-
* the terms, never that a read window ran out.
|
|
132
|
-
*/
|
|
133
|
-
export interface OrganizeCandidatePage {
|
|
134
|
-
items: OrganizeCandidate[];
|
|
135
|
-
continuationToken?: string;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** What to ask the store for, and where to resume. */
|
|
139
|
-
export interface OrganizeCandidateQuery extends LiteralClauseNarrowing {
|
|
140
|
-
limit: number;
|
|
141
|
-
continuationToken?: string;
|
|
142
|
-
mailboxIds?: readonly string[];
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Where a pass looks and which standing filter it runs for. A filter
|
|
147
|
-
* back-apply reads that filter's own FilterAnchor row by id, narrows both the
|
|
148
|
-
* literal corpus and the vector query to the inbox, and matches a body-content
|
|
149
|
-
* clause on the stored preview; an "all like these" pass sets neither.
|
|
150
|
-
*/
|
|
151
|
-
export interface OrganizeMatchScope {
|
|
152
|
-
filterId?: string;
|
|
153
|
-
mailboxIds?: readonly string[];
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export interface OrganizeMatchDeps {
|
|
157
|
-
/**
|
|
158
|
-
* Build the semantic side on demand. Invoked only for an anchored predicate,
|
|
159
|
-
* so constructing the embedder and vector store — and, on first use, the
|
|
160
|
-
* lazy import of the vector extension — is deferred until a widen is actually
|
|
161
|
-
* requested.
|
|
162
|
-
*/
|
|
163
|
-
semantic: () => OrganizeSemanticDeps;
|
|
164
|
-
/**
|
|
165
|
-
* One page of the account's messages as literal-match candidates,
|
|
166
|
-
* vector-free, with the clause terms evaluated by the store — the corpus a
|
|
167
|
-
* purely-literal (or degraded) pass reads. Paged rather than capped, so the
|
|
168
|
-
* caller's bound falls on matches instead of on how far back the read
|
|
169
|
-
* reached (#459).
|
|
170
|
-
*/
|
|
171
|
-
listAccountFilterMessages: (
|
|
172
|
-
accountConfigId: string,
|
|
173
|
-
query: OrganizeCandidateQuery,
|
|
174
|
-
) => Promise<OrganizeCandidatePage>;
|
|
175
|
-
/**
|
|
176
|
-
* Every persisted FilterAnchor for the account — read-only, always
|
|
177
|
-
* available (never gated behind {@link semantic}, since it never touches
|
|
178
|
-
* the vector store). A filter back-apply reads its own row by `filterId`
|
|
179
|
-
* (#1354); an "all like these" predicate carries only a bare
|
|
180
|
-
* `anchorMessageId`, so this is how {@link matchSemantic} finds "the
|
|
181
|
-
* standing filter this anchor came from," if one still exists, to read its
|
|
182
|
-
* fixed-at-save-time vector instead of re-deriving one from the anchor
|
|
183
|
-
* message's current chunks (reader #350 / RFC 039 Decision 1).
|
|
184
|
-
* `put` is the write half of the lazy anchor-drift repair
|
|
185
|
-
* ({@link refreshAnchorForEmbedder}), never a new standing rule.
|
|
186
|
-
*/
|
|
187
|
-
filterAnchors: Pick<
|
|
188
|
-
IFilterAnchorRepository,
|
|
189
|
-
"get" | "listByAccountConfig" | "put"
|
|
190
|
-
>;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Why the matcher refused a predicate. A refusal is an expected outcome of
|
|
195
|
-
* asking for a rule this deployment cannot evaluate, not a fault: the HTTP
|
|
196
|
-
* boundary words it as a 400 and the back-apply worker fails the job row on it,
|
|
197
|
-
* neither of which may treat it as the infrastructure failure a throw would
|
|
198
|
-
* make it (reader #463).
|
|
199
|
-
*/
|
|
200
|
-
export interface OrganizeRejection {
|
|
201
|
-
reason:
|
|
202
|
-
| "BodyContentWithoutVectorPipeline"
|
|
203
|
-
| "FilterNotActive"
|
|
204
|
-
| "FilterMissing";
|
|
205
|
-
message: string;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** The matched ids plus what the semantic side had to say about the zero it may have returned. */
|
|
209
|
-
export interface OrganizeMatched {
|
|
210
|
-
rejected: null;
|
|
211
|
-
messageIds: string[];
|
|
212
|
-
semanticUnavailable: boolean;
|
|
213
|
-
/**
|
|
214
|
-
* The widen ran and the index had nothing to answer with (issue #452): the
|
|
215
|
-
* account holds no vectors at all, or none yet for the anchor message. It is
|
|
216
|
-
* the difference between "this rule matches nothing" and "nothing is indexed
|
|
217
|
-
* yet", which a bare zero collapses into one wrong sentence. Distinct from
|
|
218
|
-
* {@link OrganizeMatched.semanticUnavailable}, which is the deployment
|
|
219
|
-
* shipping no vector pipeline at all; an empty index is not an error, so it
|
|
220
|
-
* cannot be inferred from a throw.
|
|
221
|
-
*/
|
|
222
|
-
semanticIndexEmpty: boolean;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** The predicate was refused: nothing matched, and nothing may be applied. */
|
|
226
|
-
export interface OrganizeMatchRejected {
|
|
227
|
-
rejected: OrganizeRejection;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export type OrganizeMatchResult = OrganizeMatched | OrganizeMatchRejected;
|
|
231
|
-
|
|
232
|
-
const hasAnchor = (predicate: OrganizePredicate): boolean =>
|
|
233
|
-
predicate.anchorMessageId !== NO_ACTION && predicate.anchorMessageId !== "";
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Reconstruct the literal-match view of a message from its already-indexed chunk
|
|
237
|
-
* vectors — no body fetch, no re-embedding (remit-data-heavy: frugal). The
|
|
238
|
-
* sender chunk carries the from address, the subject/body chunks the text; each
|
|
239
|
-
* is the same 512-char preview the semantic side is derived from.
|
|
240
|
-
*/
|
|
241
|
-
const filterMessageFromChunks = (
|
|
242
|
-
records: VectorRecord[],
|
|
243
|
-
): FilterMessage | null => {
|
|
244
|
-
if (records.length === 0) return null;
|
|
245
|
-
let subject = "";
|
|
246
|
-
let fromName = "";
|
|
247
|
-
let from = "";
|
|
248
|
-
let listId = "";
|
|
249
|
-
const textParts: string[] = [];
|
|
250
|
-
for (const record of records) {
|
|
251
|
-
const meta = record.metadata;
|
|
252
|
-
if (meta.subject && !subject) subject = meta.subject;
|
|
253
|
-
if (meta.fromName && !fromName) fromName = meta.fromName;
|
|
254
|
-
if (meta.listId && !listId) listId = meta.listId;
|
|
255
|
-
if (meta.chunkType === "sender" && meta.textPreview && !from) {
|
|
256
|
-
from = meta.textPreview;
|
|
257
|
-
}
|
|
258
|
-
if (
|
|
259
|
-
(meta.chunkType === "body" || meta.chunkType === "subject") &&
|
|
260
|
-
meta.textPreview
|
|
261
|
-
) {
|
|
262
|
-
textParts.push(meta.textPreview);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return { from, fromName, subject, listId, text: textParts.join("\n") };
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The persisted FilterAnchor whose `anchorMessageId` matches this predicate's
|
|
270
|
-
* anchor, if the account has a standing filter built from that message (RFC
|
|
271
|
-
* 034 Decision 2 / RFC 039 Decision 1, reader #350). An "all like these"
|
|
272
|
-
* predicate carries no `filterId` — that job structurally is not a Filter — so
|
|
273
|
-
* the only way to recover "the filter this anchor came from" is by the value it
|
|
274
|
-
* was anchored on. The scan is over one account's (small, bounded) anchor
|
|
275
|
-
* rows, read-only, and never touches the vector store. Returns `undefined`
|
|
276
|
-
* when no standing filter was ever anchored on this message (or it has since
|
|
277
|
-
* been deleted), in which case the caller falls back to deriving the anchor
|
|
278
|
-
* live from the message's current chunk vectors, exactly as before. The whole
|
|
279
|
-
* row is returned, not just its payload, so the caller can run the same
|
|
280
|
-
* anchor-drift repair index-time matching does.
|
|
281
|
-
*/
|
|
282
|
-
const findPersistedAnchor = async (
|
|
283
|
-
filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig">,
|
|
284
|
-
accountConfigId: string,
|
|
285
|
-
anchorMessageId: string,
|
|
286
|
-
): Promise<FilterAnchorItem | undefined> =>
|
|
287
|
-
(await filterAnchors.listByAccountConfig(accountConfigId)).find(
|
|
288
|
-
(anchor) => anchor.anchorMessageId === anchorMessageId,
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* The anchor the widen queries with: the persisted row, re-embedded in place
|
|
293
|
-
* when the embedding model has drifted since it was written. A deployment that
|
|
294
|
-
* ships no embedding model cannot repair it, and the kNN read itself needs
|
|
295
|
-
* none, so that case keeps querying with the stored vector rather than going
|
|
296
|
-
* dark — and is classified without recording the absence, which would
|
|
297
|
-
* otherwise disable every later `/search/semantic` (see
|
|
298
|
-
* `semantic-capability.ts`).
|
|
299
|
-
*
|
|
300
|
-
* The classifier is wider than a missing embedder: `LocalEmbeddingService`
|
|
301
|
-
* raises ERR_EMBEDDING_MODEL_UNAVAILABLE for anything that stops the pipeline
|
|
302
|
-
* loading, a corrupt model file included, and a write failure carrying one of
|
|
303
|
-
* those codes lands here too. Any of them falls back to the stale vector, so
|
|
304
|
-
* the fallback is logged — it is the wrong-score condition this repair exists
|
|
305
|
-
* to close, taken deliberately over returning nothing. Every other failure
|
|
306
|
-
* propagates.
|
|
307
|
-
*/
|
|
308
|
-
const anchorForWiden = async (
|
|
309
|
-
deps: OrganizeMatchDeps,
|
|
310
|
-
semantic: OrganizeSemanticDeps,
|
|
311
|
-
persisted: FilterAnchorItem,
|
|
312
|
-
): Promise<FilterAnchorItem> =>
|
|
313
|
-
refreshAnchorForEmbedder(
|
|
314
|
-
{ anchorRepository: deps.filterAnchors, embedder: semantic },
|
|
315
|
-
persisted,
|
|
316
|
-
).catch((error: unknown) => {
|
|
317
|
-
if (!isSemanticCapabilityAbsence(error)) throw error;
|
|
318
|
-
logger.warn(
|
|
319
|
-
{
|
|
320
|
-
alert: "filter_anchor_repair_unavailable",
|
|
321
|
-
filterId: persisted.filterId,
|
|
322
|
-
accountConfigId: persisted.accountConfigId,
|
|
323
|
-
errorName: (error as { name?: string })?.name,
|
|
324
|
-
error: inspect(error),
|
|
325
|
-
},
|
|
326
|
-
"Cannot re-embed a drifted anchor in this deployment; widening on the stored (previous-model) vector rather than returning nothing",
|
|
327
|
-
);
|
|
328
|
-
return persisted;
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* The semantic (anchor) arm: read the anchor vector — the persisted
|
|
333
|
-
* `FilterAnchor` for a message a standing filter was built from (fixed at
|
|
334
|
-
* save time, unaffected by the anchor message's later deletion or
|
|
335
|
-
* re-chunking, and re-embedded in place when the embedding model has drifted
|
|
336
|
-
* since — {@link refreshAnchorForEmbedder}), or else pool it fresh from the
|
|
337
|
-
* anchor message's existing chunk vectors, the same as before this filter
|
|
338
|
-
* existed or ever had one.
|
|
339
|
-
* Fan out with a k-NN query gated on the cosine threshold, then refine by
|
|
340
|
-
* literal clauses reconstructed from the same chunk vectors. Every read here
|
|
341
|
-
* goes through the vector store; a deployment without the vector pipeline
|
|
342
|
-
* fails on the first call, which {@link matchOrganize} catches.
|
|
343
|
-
*
|
|
344
|
-
* `indexEmpty` separates the two ways this arm returns nothing (issue #452):
|
|
345
|
-
* neither a persisted anchor nor the anchor message's own chunk vectors exist
|
|
346
|
-
* to pool from, or the k-NN read came back with no rows at all for the account.
|
|
347
|
-
* Both mean the index has nothing to answer with — it is empty or still
|
|
348
|
-
* building — rather than that no mail resembles the anchor. A thresholded-away
|
|
349
|
-
* result set is a real answer and is not flagged.
|
|
350
|
-
*/
|
|
351
|
-
const matchSemantic = async (
|
|
352
|
-
deps: OrganizeMatchDeps,
|
|
353
|
-
accountConfigId: string,
|
|
354
|
-
predicate: OrganizePredicate,
|
|
355
|
-
limit: number,
|
|
356
|
-
scope: OrganizeMatchScope,
|
|
357
|
-
): Promise<{ messageIds: string[]; indexEmpty: boolean }> => {
|
|
358
|
-
const semantic = deps.semantic();
|
|
359
|
-
const persisted = scope.filterId
|
|
360
|
-
? ((await deps.filterAnchors.get(accountConfigId, scope.filterId)) ??
|
|
361
|
-
undefined)
|
|
362
|
-
: await findPersistedAnchor(
|
|
363
|
-
deps.filterAnchors,
|
|
364
|
-
accountConfigId,
|
|
365
|
-
predicate.anchorMessageId,
|
|
366
|
-
);
|
|
367
|
-
const anchor: AnchorPayload | null = persisted
|
|
368
|
-
? await anchorForWiden(deps, semantic, persisted)
|
|
369
|
-
: await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId);
|
|
370
|
-
if (!anchor) return { messageIds: [], indexEmpty: true };
|
|
371
|
-
const threshold =
|
|
372
|
-
predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
|
|
373
|
-
const folders: readonly (string | undefined)[] = scope.mailboxIds ?? [
|
|
374
|
-
undefined,
|
|
375
|
-
];
|
|
376
|
-
const pages = await Promise.all(
|
|
377
|
-
folders.map((mailboxId) =>
|
|
378
|
-
semantic.vectorStore.query({
|
|
379
|
-
vector: anchor.anchorEmbedding,
|
|
380
|
-
topK: limit * VECTOR_CHUNK_FACTOR,
|
|
381
|
-
filter: mailboxId
|
|
382
|
-
? { accountConfigId, mailboxId }
|
|
383
|
-
: { accountConfigId },
|
|
384
|
-
}),
|
|
385
|
-
),
|
|
386
|
-
);
|
|
387
|
-
const matches = pages.flat();
|
|
388
|
-
if (matches.length === 0) return { messageIds: [], indexEmpty: true };
|
|
389
|
-
const bestScore = new Map<string, number>();
|
|
390
|
-
for (const match of matches) {
|
|
391
|
-
const messageId = match.metadata.messageId;
|
|
392
|
-
const prev = bestScore.get(messageId);
|
|
393
|
-
if (prev === undefined || match.score > prev) {
|
|
394
|
-
bestScore.set(messageId, match.score);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
const base = [...bestScore.entries()]
|
|
398
|
-
.filter(([, score]) => score >= threshold)
|
|
399
|
-
.map(([messageId]) => messageId);
|
|
400
|
-
|
|
401
|
-
const clauses = predicate.literalClauses;
|
|
402
|
-
if (clauses.length === 0)
|
|
403
|
-
return { messageIds: base.slice(0, limit), indexEmpty: false };
|
|
404
|
-
|
|
405
|
-
const matched: string[] = [];
|
|
406
|
-
for (const messageId of base) {
|
|
407
|
-
if (matched.length >= limit) break;
|
|
408
|
-
const records = await semantic.vectorStore.getByMessage(messageId);
|
|
409
|
-
const message = filterMessageFromChunks(records);
|
|
410
|
-
if (!message) continue;
|
|
411
|
-
if (literalClausesMatch(clauses, predicate.matchOperator, message)) {
|
|
412
|
-
matched.push(messageId);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
return { messageIds: matched, indexEmpty: false };
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* A `HasWords` clause matches the message body (RFC 031, `clauseMatches`). The
|
|
420
|
-
* live index-time filter evaluates it against the full parsed body
|
|
421
|
-
* (`body-sync.ts` `toFilterMessage`); the vector-free corpus slice carries only
|
|
422
|
-
* `From`/`Subject` at full fidelity and no faithful body, so it cannot serve a
|
|
423
|
-
* body-content clause without silently narrowing the match to whatever preview
|
|
424
|
-
* happened to be indexed. Body-content matching therefore requires the widen
|
|
425
|
-
* (vector) path — {@link matchSemantic} reconstructs body text from chunk
|
|
426
|
-
* previews there. This guard keeps the two matchers from diverging silently: a
|
|
427
|
-
* body-content clause reaching the vector-free path is refused instead of
|
|
428
|
-
* returning a wrong set.
|
|
429
|
-
*/
|
|
430
|
-
export const BODY_CONTENT_REJECTION_MESSAGE =
|
|
431
|
-
"Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path";
|
|
432
|
-
|
|
433
|
-
const bodyContentRejection = (
|
|
434
|
-
clauses: OrganizePredicate["literalClauses"],
|
|
435
|
-
): OrganizeRejection | null =>
|
|
436
|
-
clauses.some((clause) => clause.field === FilterClauseField.HasWords)
|
|
437
|
-
? {
|
|
438
|
-
reason: "BodyContentWithoutVectorPipeline",
|
|
439
|
-
message: BODY_CONTENT_REJECTION_MESSAGE,
|
|
440
|
-
}
|
|
441
|
-
: null;
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* The refusal a predicate carries on its own, decidable without reading the
|
|
445
|
-
* corpus: an anchorless predicate can only ever take the vector-free literal
|
|
446
|
-
* path, so a body-content clause on it is refused up front. `createOrganizeJob`
|
|
447
|
-
* runs this before enqueueing and {@link matchOrganize} runs it on the
|
|
448
|
-
* anchorless arm, so preview, the worker and the request boundary all refuse the
|
|
449
|
-
* same rule (reader #463).
|
|
450
|
-
*/
|
|
451
|
-
export const organizePredicateRejection = (
|
|
452
|
-
predicate: OrganizePredicate,
|
|
453
|
-
): OrganizeRejection | null =>
|
|
454
|
-
hasAnchor(predicate) ? null : bodyContentRejection(predicate.literalClauses);
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* The literal-only arm: the store selects on the clause terms, this refines
|
|
458
|
-
* what it returned and stops at `limit` MATCHES. Used both for a purely-literal
|
|
459
|
-
* predicate and as the degraded fallback when a widen is requested on a
|
|
460
|
-
* deployment without the vector pipeline. Serves `From`/`Subject` clauses at
|
|
461
|
-
* full fidelity from the core thread rows; body-content (`HasWords`) clauses are
|
|
462
|
-
* refused before this runs (see {@link bodyContentRejection}).
|
|
463
|
-
*
|
|
464
|
-
* The terms are a narrowing, not the verdict ({@link literalClauseTerms}), so
|
|
465
|
-
* every page is still run through {@link literalClausesMatch} — and paging
|
|
466
|
-
* continues until the cap fills or the store runs out, which is what makes the
|
|
467
|
-
* bound a bound on results rather than on the newest `limit` rows (#459).
|
|
468
|
-
* Deduped by message id: the same mail filed in two folders is one match.
|
|
469
|
-
*/
|
|
470
|
-
const matchLiteral = async (
|
|
471
|
-
deps: OrganizeMatchDeps,
|
|
472
|
-
accountConfigId: string,
|
|
473
|
-
predicate: OrganizePredicate,
|
|
474
|
-
limit: number,
|
|
475
|
-
scope: OrganizeMatchScope,
|
|
476
|
-
): Promise<string[]> => {
|
|
477
|
-
const clauses = predicate.literalClauses;
|
|
478
|
-
const narrowing = literalClauseTerms(clauses, predicate.matchOperator);
|
|
479
|
-
if (!narrowing) return [];
|
|
480
|
-
|
|
481
|
-
const matched: string[] = [];
|
|
482
|
-
const seen = new Set<string>();
|
|
483
|
-
let continuationToken: string | undefined;
|
|
484
|
-
do {
|
|
485
|
-
const page = await deps.listAccountFilterMessages(accountConfigId, {
|
|
486
|
-
...narrowing,
|
|
487
|
-
limit,
|
|
488
|
-
continuationToken,
|
|
489
|
-
mailboxIds: scope.mailboxIds,
|
|
490
|
-
});
|
|
491
|
-
for (const { messageId, message } of page.items) {
|
|
492
|
-
if (seen.has(messageId)) continue;
|
|
493
|
-
seen.add(messageId);
|
|
494
|
-
if (!literalClausesMatch(clauses, predicate.matchOperator, message)) {
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
matched.push(messageId);
|
|
498
|
-
if (matched.length >= limit) return matched;
|
|
499
|
-
}
|
|
500
|
-
continuationToken = page.continuationToken;
|
|
501
|
-
} while (continuationToken);
|
|
502
|
-
return matched;
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* The matcher shared by preview and apply — the previewed set equals the applied
|
|
507
|
-
* set for the same input. Read-only. Returns the matching message ids, bounded,
|
|
508
|
-
* and whether the semantic widen was skipped as unavailable.
|
|
509
|
-
*
|
|
510
|
-
* - Semantic anchor: {@link matchSemantic}, built lazily so a literal-only
|
|
511
|
-
* predicate never touches the vector extension.
|
|
512
|
-
* - Literal clauses (RFC 031) refine the candidate set; a purely-literal
|
|
513
|
-
* back-apply scans a bounded vector-free slice of the corpus instead.
|
|
514
|
-
* - A predicate with neither an anchor nor a clause matches nothing, mirroring
|
|
515
|
-
* the index-time filter matcher.
|
|
516
|
-
* - When a widen is requested but this deployment ships no vector pipeline, the
|
|
517
|
-
* capability-absence is absorbed exactly as `/search/semantic` absorbs it:
|
|
518
|
-
* the literal matches are returned (empty for an anchor-only predicate) with
|
|
519
|
-
* `semanticUnavailable` set, never a 500. Any other failure propagates.
|
|
520
|
-
* - A predicate the vector-free path cannot evaluate (a body-content clause,
|
|
521
|
-
* see {@link bodyContentRejection}) comes back as a rejection, so a caller
|
|
522
|
-
* can tell a refused rule from a broken deployment (reader #463).
|
|
523
|
-
*/
|
|
524
|
-
export const matchOrganize = async (
|
|
525
|
-
deps: OrganizeMatchDeps,
|
|
526
|
-
accountConfigId: string,
|
|
527
|
-
predicate: OrganizePredicate,
|
|
528
|
-
limit: number = ORGANIZE_MATCH_LIMIT,
|
|
529
|
-
scope: OrganizeMatchScope = {},
|
|
530
|
-
): Promise<OrganizeMatchResult> => {
|
|
531
|
-
const anchored = hasAnchor(predicate);
|
|
532
|
-
const clauses = predicate.literalClauses;
|
|
533
|
-
if (!anchored && clauses.length === 0) {
|
|
534
|
-
return {
|
|
535
|
-
rejected: null,
|
|
536
|
-
messageIds: [],
|
|
537
|
-
semanticUnavailable: false,
|
|
538
|
-
semanticIndexEmpty: false,
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (!anchored) {
|
|
543
|
-
const rejection = scope.filterId
|
|
544
|
-
? null
|
|
545
|
-
: organizePredicateRejection(predicate);
|
|
546
|
-
if (rejection) return { rejected: rejection };
|
|
547
|
-
const messageIds = await matchLiteral(
|
|
548
|
-
deps,
|
|
549
|
-
accountConfigId,
|
|
550
|
-
predicate,
|
|
551
|
-
limit,
|
|
552
|
-
scope,
|
|
553
|
-
);
|
|
554
|
-
return {
|
|
555
|
-
rejected: null,
|
|
556
|
-
messageIds,
|
|
557
|
-
semanticUnavailable: false,
|
|
558
|
-
semanticIndexEmpty: false,
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
try {
|
|
563
|
-
const semantic = await matchSemantic(
|
|
564
|
-
deps,
|
|
565
|
-
accountConfigId,
|
|
566
|
-
predicate,
|
|
567
|
-
limit,
|
|
568
|
-
scope,
|
|
569
|
-
);
|
|
570
|
-
return {
|
|
571
|
-
rejected: null,
|
|
572
|
-
messageIds: semantic.messageIds,
|
|
573
|
-
semanticUnavailable: false,
|
|
574
|
-
semanticIndexEmpty: semantic.indexEmpty,
|
|
575
|
-
};
|
|
576
|
-
} catch (error) {
|
|
577
|
-
if (!noteSemanticCapabilityAbsence(error)) throw error;
|
|
578
|
-
// The widen cannot run on this deployment. Fall back to the literal
|
|
579
|
-
// clauses over the corpus — nothing when the predicate is anchor-only —
|
|
580
|
-
// and flag the absence so the client can say so.
|
|
581
|
-
if (clauses.length === 0) {
|
|
582
|
-
return {
|
|
583
|
-
rejected: null,
|
|
584
|
-
messageIds: [],
|
|
585
|
-
semanticUnavailable: true,
|
|
586
|
-
semanticIndexEmpty: false,
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
const rejection = scope.filterId ? null : bodyContentRejection(clauses);
|
|
590
|
-
if (rejection) return { rejected: rejection };
|
|
591
|
-
const messageIds = await matchLiteral(
|
|
592
|
-
deps,
|
|
593
|
-
accountConfigId,
|
|
594
|
-
predicate,
|
|
595
|
-
limit,
|
|
596
|
-
scope,
|
|
597
|
-
);
|
|
598
|
-
return {
|
|
599
|
-
rejected: null,
|
|
600
|
-
messageIds,
|
|
601
|
-
semanticUnavailable: true,
|
|
602
|
-
semanticIndexEmpty: false,
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
};
|
|
606
|
-
|
|
607
|
-
let cachedSemantic: OrganizeSemanticDeps | null = null;
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Construct the semantic side from env on first use and memoize it. The embedder
|
|
611
|
-
* and vector store are selected by env, independent of `DATA_BACKEND`; building
|
|
612
|
-
* them is cheap (the vector extension imports lazily on the first store call),
|
|
613
|
-
* but deferring construction keeps a literal-only preview clear of the whole
|
|
614
|
-
* semantic graph.
|
|
615
|
-
*/
|
|
616
|
-
const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
617
|
-
if (cachedSemantic) return cachedSemantic;
|
|
618
|
-
const embedder = buildEmbeddingServiceFromEnv();
|
|
619
|
-
const store = buildVectorStoreFromEnv(embedder.dimensions);
|
|
620
|
-
cachedSemantic = {
|
|
621
|
-
buildAnchor: (accountConfigId, anchorMessageId) =>
|
|
622
|
-
buildMessageAnchor({ store }, { accountConfigId, anchorMessageId }),
|
|
623
|
-
vectorStore: store,
|
|
624
|
-
embed: async (text) => (await embedder.embed([text]))[0],
|
|
625
|
-
embeddingId: embedder.embeddingId,
|
|
626
|
-
};
|
|
627
|
-
return cachedSemantic;
|
|
628
|
-
};
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* The vector-free corpus read for a literal back-apply: one page of the
|
|
632
|
-
* account's messages, newest-first across every mailbox, with the clause terms
|
|
633
|
-
* evaluated in the query. Reads the core thread rows, so it runs on a
|
|
634
|
-
* deployment that ships no vector pipeline.
|
|
635
|
-
*
|
|
636
|
-
* `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
|
|
637
|
-
* `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
|
|
638
|
-
* does at index time. `text` is the row's stored body preview (`snippet`),
|
|
639
|
-
* the same text search matches free words against. An "all like these" pass
|
|
640
|
-
* refuses a body-content (`HasWords`) clause before reading it (see
|
|
641
|
-
* {@link bodyContentRejection}); a filter back-apply matches it on the
|
|
642
|
-
* preview, the mail search showed the user when the rule was made (#1354).
|
|
643
|
-
*/
|
|
644
|
-
const listAccountFilterMessagesFromClient =
|
|
645
|
-
(client: RemitClient): OrganizeMatchDeps["listAccountFilterMessages"] =>
|
|
646
|
-
async (accountConfigId, query) => {
|
|
647
|
-
const page = await client.threadMessage.listByFieldTerms(
|
|
648
|
-
accountConfigId,
|
|
649
|
-
query.terms,
|
|
650
|
-
{
|
|
651
|
-
operator: query.operator,
|
|
652
|
-
limit: query.limit,
|
|
653
|
-
continuationToken: query.continuationToken,
|
|
654
|
-
excludeDeleted: true,
|
|
655
|
-
mailboxIds: query.mailboxIds,
|
|
656
|
-
},
|
|
657
|
-
);
|
|
658
|
-
return {
|
|
659
|
-
items: page.items.map((row) => ({
|
|
660
|
-
messageId: row.messageId,
|
|
661
|
-
message: {
|
|
662
|
-
from: row.fromEmail ?? "",
|
|
663
|
-
fromName: row.fromName ?? "",
|
|
664
|
-
subject: row.subject ?? "",
|
|
665
|
-
text: row.snippet ?? "",
|
|
666
|
-
listId: row.listId ?? "",
|
|
667
|
-
},
|
|
668
|
-
})),
|
|
669
|
-
continuationToken: page.continuationToken,
|
|
670
|
-
};
|
|
671
|
-
};
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* The env-wired matcher deps for the running backend/worker. The semantic side
|
|
675
|
-
* is built lazily so a literal-only preview never constructs it.
|
|
676
|
-
*/
|
|
677
|
-
export const buildOrganizeMatchDeps = (
|
|
678
|
-
client: RemitClient,
|
|
679
|
-
): OrganizeMatchDeps => ({
|
|
680
|
-
semantic: buildSemanticFromEnv,
|
|
681
|
-
listAccountFilterMessages: listAccountFilterMessagesFromClient(client),
|
|
682
|
-
filterAnchors: client.filterAnchor,
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* The exclusive-move arm of a back-apply, wired exactly like the body-sync
|
|
687
|
-
* placement mover (`sync-message-body.ts`): the local-first
|
|
688
|
-
* {@link PlacementMoveService} over the shared message-management queue. A move
|
|
689
|
-
* commits locally (marker + ThreadMessage + Message row) and enqueues a
|
|
690
|
-
* `PLACEMENT_MOVE_PUSH` for the reconciler; its marker state engine makes a
|
|
691
|
-
* redelivered job idempotent (mirrors #1297). Returns `undefined` when no
|
|
692
|
-
* message-management queue is wired — the same gate body sync uses to keep the
|
|
693
|
-
* move path off — so a move requested in that environment is counted as failed
|
|
694
|
-
* rather than silently dropped (see {@link applyOrganize}).
|
|
695
|
-
*/
|
|
696
|
-
export const buildOrganizeMoveService = (
|
|
697
|
-
client: RemitClient,
|
|
698
|
-
): PlacementMoveService | undefined => {
|
|
699
|
-
const queueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
700
|
-
if (!queueUrl) return undefined;
|
|
701
|
-
return new PlacementMoveService({
|
|
702
|
-
messageService: client.message,
|
|
703
|
-
threadMessageService: client.threadMessage,
|
|
704
|
-
markerService: client.placementMove,
|
|
705
|
-
addressService: client.address,
|
|
706
|
-
mailboxSpecialUseService: client.mailboxSpecialUse,
|
|
707
|
-
sqsQueueUrl: queueUrl,
|
|
708
|
-
});
|
|
709
|
-
};
|
|
710
|
-
|
|
711
|
-
export interface ApplyOrganizeDeps {
|
|
712
|
-
client: RemitClient;
|
|
713
|
-
moveService?: PlacementMoveService;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
export interface ApplyOrganizeResult {
|
|
717
|
-
applied: number;
|
|
718
|
-
failed: number;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Apply the back-apply action to every matched message, reusing the index-time
|
|
723
|
-
* apply plumbing: an idempotent MessageLabel upsert (additive) with
|
|
724
|
-
* `appliedByFilterId` deliberately ABSENT — this path attributes to no filter,
|
|
725
|
-
* exactly like a hand-applied label (RFC 034 Decision 3.3) — and an idempotent
|
|
726
|
-
* folder move (exclusive). One poisoned message never fails the batch; it is
|
|
727
|
-
* counted as failed and the pass continues.
|
|
728
|
-
*
|
|
729
|
-
* Every requested move happens. A standing filter acts at exactly two moments —
|
|
730
|
-
* when a message is first seen, and when the user presses "run rules now" — and
|
|
731
|
-
* holds no claim on that message afterwards, so it never arbitrates against a
|
|
732
|
-
* later user-initiated apply (reader #497). Cross-filter precedence stays where
|
|
733
|
-
* two filters collide over the same message at the same trigger
|
|
734
|
-
* (`FilterPipeline`/`selectMoveWinner`), and `applied` therefore counts moves
|
|
735
|
-
* that actually happened.
|
|
736
|
-
*/
|
|
737
|
-
export const applyOrganize = async (
|
|
738
|
-
deps: ApplyOrganizeDeps,
|
|
739
|
-
accountConfigId: string,
|
|
740
|
-
messageIds: readonly string[],
|
|
741
|
-
predicate: OrganizePredicate,
|
|
742
|
-
): Promise<ApplyOrganizeResult> => {
|
|
743
|
-
const { client, moveService } = deps;
|
|
744
|
-
const applyLabel =
|
|
745
|
-
predicate.actionLabelId !== NO_ACTION && predicate.actionLabelId !== "";
|
|
746
|
-
const applyMove =
|
|
747
|
-
predicate.actionMailboxId !== NO_ACTION && predicate.actionMailboxId !== "";
|
|
748
|
-
|
|
749
|
-
const applyToMessage = async (messageId: string): Promise<void> => {
|
|
750
|
-
if (applyLabel) {
|
|
751
|
-
await client.messageLabel.apply({
|
|
752
|
-
messageId,
|
|
753
|
-
labelId: predicate.actionLabelId,
|
|
754
|
-
accountConfigId,
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
if (applyMove) {
|
|
758
|
-
if (!moveService) {
|
|
759
|
-
// An exclusive move was requested but this caller wired no move
|
|
760
|
-
// service. Never silently pretend it applied — surface it as a
|
|
761
|
-
// failed message so the job's failedCount reflects reality.
|
|
762
|
-
throw new Error(
|
|
763
|
-
"Organize move action requested but no move service is wired",
|
|
764
|
-
);
|
|
765
|
-
}
|
|
766
|
-
const message = await client.message.get(messageId);
|
|
767
|
-
const accountId = await client.mailbox.resolveAccountId(
|
|
768
|
-
message.mailboxId,
|
|
769
|
-
);
|
|
770
|
-
if (!accountId) {
|
|
771
|
-
throw new Error(
|
|
772
|
-
`Cannot resolve owning account for mailbox ${message.mailboxId}`,
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
await moveService.moveMessage(
|
|
776
|
-
accountConfigId,
|
|
777
|
-
messageId,
|
|
778
|
-
predicate.actionMailboxId,
|
|
779
|
-
accountId,
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
};
|
|
783
|
-
|
|
784
|
-
let applied = 0;
|
|
785
|
-
let failed = 0;
|
|
786
|
-
for (const messageId of messageIds) {
|
|
787
|
-
// Per-message isolation: one poisoned message is counted and skipped, the
|
|
788
|
-
// pass continues. `.catch()` (not a block try/catch) keeps this inside the
|
|
789
|
-
// no-silent-catch ban.
|
|
790
|
-
const ok = await applyToMessage(messageId)
|
|
791
|
-
.then(() => true)
|
|
792
|
-
.catch(() => false);
|
|
793
|
-
if (ok) {
|
|
794
|
-
applied += 1;
|
|
795
|
-
} else {
|
|
796
|
-
failed += 1;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
return { applied, failed };
|
|
800
|
-
};
|
|
801
|
-
|
|
802
|
-
const FILTER_NOT_ACTIVE_MESSAGE =
|
|
803
|
-
"This filter is turned off or has expired. Turn it on to run it over the inbox.";
|
|
804
|
-
|
|
805
|
-
const FILTER_MISSING_MESSAGE =
|
|
806
|
-
"This filter was deleted before it could run over the inbox.";
|
|
807
|
-
|
|
808
|
-
const rejectFilter = (
|
|
809
|
-
reason: OrganizeRejection["reason"],
|
|
810
|
-
message: string,
|
|
811
|
-
): OrganizeMatchRejected => ({ rejected: { reason, message } });
|
|
812
|
-
|
|
813
|
-
/**
|
|
814
|
-
* A standing filter's predicate and action, read the way index time reads
|
|
815
|
-
* them. `matchSemantic` reads the anchor vector by `filterId`; the anchor
|
|
816
|
-
* message id here only says the filter widens.
|
|
817
|
-
*/
|
|
818
|
-
const predicateFromFilter = (
|
|
819
|
-
filter: FilterItem,
|
|
820
|
-
anchorMessageId: string,
|
|
821
|
-
): OrganizePredicate => ({
|
|
822
|
-
anchorMessageId,
|
|
823
|
-
matchOperator: filter.matchOperator,
|
|
824
|
-
literalClauses: filter.literalClauses,
|
|
825
|
-
similarityThreshold: DEFAULT_SEMANTIC_MATCH_THRESHOLD,
|
|
826
|
-
actionLabelId: filter.actionLabelId,
|
|
827
|
-
actionMailboxId: filter.actionMailboxId,
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
interface LoadedFilter {
|
|
831
|
-
rejected: null;
|
|
832
|
-
filter: FilterItem;
|
|
833
|
-
predicate: OrganizePredicate;
|
|
834
|
-
/**
|
|
835
|
-
* Marked `hasAnchor` with its FilterAnchor row gone. Index time matches
|
|
836
|
-
* such a filter against nothing, so the back-apply does too.
|
|
837
|
-
*/
|
|
838
|
-
matchesNothing: boolean;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Read the filter a job names: refused when it was deleted or is not Active,
|
|
843
|
-
* since index time evaluates neither.
|
|
844
|
-
*/
|
|
845
|
-
const loadFilter = async (
|
|
846
|
-
client: Pick<RemitClient, "filter" | "filterAnchor">,
|
|
847
|
-
accountConfigId: string,
|
|
848
|
-
filterId: string,
|
|
849
|
-
): Promise<LoadedFilter | OrganizeMatchRejected> => {
|
|
850
|
-
const stored = await client.filter
|
|
851
|
-
.get(accountConfigId, filterId)
|
|
852
|
-
.catch((error: unknown) => {
|
|
853
|
-
if (error instanceof NotFoundError) return null;
|
|
854
|
-
throw error;
|
|
855
|
-
});
|
|
856
|
-
if (!stored) return rejectFilter("FilterMissing", FILTER_MISSING_MESSAGE);
|
|
857
|
-
const filter = await client.filter.refreshExpiry(stored);
|
|
858
|
-
if (filter.state !== FilterState.Active) {
|
|
859
|
-
return rejectFilter("FilterNotActive", FILTER_NOT_ACTIVE_MESSAGE);
|
|
860
|
-
}
|
|
861
|
-
if (!filter.hasAnchor) {
|
|
862
|
-
return {
|
|
863
|
-
rejected: null,
|
|
864
|
-
filter,
|
|
865
|
-
predicate: predicateFromFilter(filter, NO_ACTION),
|
|
866
|
-
matchesNothing: false,
|
|
867
|
-
};
|
|
868
|
-
}
|
|
869
|
-
const anchor = await client.filterAnchor.get(accountConfigId, filterId);
|
|
870
|
-
return {
|
|
871
|
-
rejected: null,
|
|
872
|
-
filter,
|
|
873
|
-
predicate: predicateFromFilter(
|
|
874
|
-
filter,
|
|
875
|
-
anchor?.anchorMessageId ?? NO_ACTION,
|
|
876
|
-
),
|
|
877
|
-
matchesNothing: anchor === null,
|
|
878
|
-
};
|
|
879
|
-
};
|
|
880
|
-
|
|
881
|
-
/** Every inbox under the config — the folders a filter back-apply runs over. */
|
|
882
|
-
const inboxMailboxIds = async (
|
|
883
|
-
client: Pick<RemitClient, "account" | "mailboxSpecialUse">,
|
|
884
|
-
accountConfigId: string,
|
|
885
|
-
): Promise<string[]> => {
|
|
886
|
-
const accounts =
|
|
887
|
-
await client.account.listMailSourcesByAccountConfig(accountConfigId);
|
|
888
|
-
const inboxes = await Promise.all(
|
|
889
|
-
accounts.map((account) =>
|
|
890
|
-
client.mailboxSpecialUse.findInboxMailbox(account.accountId),
|
|
891
|
-
),
|
|
892
|
-
);
|
|
893
|
-
return inboxes.flatMap((inbox) => (inbox ? [inbox.mailboxId] : []));
|
|
894
|
-
};
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* The message as the index-time pipeline reads it, from its thread row. The
|
|
898
|
-
* body is the stored preview, so a `HasWords` clause matches what search
|
|
899
|
-
* matched. A message deleted since its vector was written has no row.
|
|
900
|
-
*/
|
|
901
|
-
const filterMessageOf = async (
|
|
902
|
-
client: Pick<RemitClient, "threadMessage">,
|
|
903
|
-
accountConfigId: string,
|
|
904
|
-
messageId: string,
|
|
905
|
-
): Promise<FilterMessage | null> => {
|
|
906
|
-
const row = await client.threadMessage
|
|
907
|
-
.getByMessageId(accountConfigId, messageId)
|
|
908
|
-
.catch((error: unknown) => {
|
|
909
|
-
if (error instanceof NotFoundError) return null;
|
|
910
|
-
throw error;
|
|
911
|
-
});
|
|
912
|
-
if (!row) return null;
|
|
913
|
-
return {
|
|
914
|
-
from: row.fromEmail ?? "",
|
|
915
|
-
fromName: row.fromName ?? "",
|
|
916
|
-
subject: row.subject ?? "",
|
|
917
|
-
text: row.snippet ?? "",
|
|
918
|
-
listId: row.listId ?? "",
|
|
919
|
-
};
|
|
920
|
-
};
|
|
921
|
-
|
|
922
|
-
export interface FilterBackApplyResult {
|
|
923
|
-
rejected: null;
|
|
924
|
-
matched: number;
|
|
925
|
-
applied: number;
|
|
926
|
-
failed: number;
|
|
927
|
-
semanticUnavailable: boolean;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
export interface FilterBackApplyDeps {
|
|
931
|
-
client: RemitClient;
|
|
932
|
-
matchDeps: OrganizeMatchDeps;
|
|
933
|
-
moveService?: PlacementMoveService;
|
|
934
|
-
/** The embedder index time uses; read from env when absent. */
|
|
935
|
-
embedder?: MessageEmbedder;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Run one standing filter over the inbox as if each matching message had just
|
|
940
|
-
* arrived. Every message the matcher returns is a match: its label always
|
|
941
|
-
* applies, being additive. The index-time {@link FilterPipeline} only orders
|
|
942
|
-
* the exclusive move, so the move is skipped where it names another active
|
|
943
|
-
* filter. The pipeline re-scores a similarity filter against the stored
|
|
944
|
-
* preview rather than the full body, so its verdict on whether this filter
|
|
945
|
-
* matches is not asked; and an evaluate it could not finish names no other
|
|
946
|
-
* filter, so the move goes ahead rather than silently dropping out.
|
|
947
|
-
*/
|
|
948
|
-
export const backApplyFilter = async (
|
|
949
|
-
deps: FilterBackApplyDeps,
|
|
950
|
-
accountConfigId: string,
|
|
951
|
-
filterId: string,
|
|
952
|
-
): Promise<FilterBackApplyResult | OrganizeMatchRejected> => {
|
|
953
|
-
const { client, matchDeps, moveService } = deps;
|
|
954
|
-
const loaded = await loadFilter(client, accountConfigId, filterId);
|
|
955
|
-
if (loaded.rejected) return loaded;
|
|
956
|
-
const config = buildFilterConfig(
|
|
957
|
-
{
|
|
958
|
-
filterService: client.filter,
|
|
959
|
-
filterAnchorService: client.filterAnchor,
|
|
960
|
-
messageLabelService: client.messageLabel,
|
|
961
|
-
placementMoveService: moveService,
|
|
962
|
-
},
|
|
963
|
-
deps.embedder,
|
|
964
|
-
);
|
|
965
|
-
if (!config) {
|
|
966
|
-
throw new Error(
|
|
967
|
-
"Filter back-apply needs the message-management queue to move mail",
|
|
968
|
-
);
|
|
969
|
-
}
|
|
970
|
-
const pipeline = new FilterPipeline(config, logger);
|
|
971
|
-
|
|
972
|
-
const mailboxIds = await inboxMailboxIds(client, accountConfigId);
|
|
973
|
-
const nothing: FilterBackApplyResult = {
|
|
974
|
-
rejected: null,
|
|
975
|
-
matched: 0,
|
|
976
|
-
applied: 0,
|
|
977
|
-
failed: 0,
|
|
978
|
-
semanticUnavailable: false,
|
|
979
|
-
};
|
|
980
|
-
if (loaded.matchesNothing || mailboxIds.length === 0) return nothing;
|
|
981
|
-
|
|
982
|
-
const match = await matchOrganize(
|
|
983
|
-
matchDeps,
|
|
984
|
-
accountConfigId,
|
|
985
|
-
loaded.predicate,
|
|
986
|
-
ORGANIZE_MATCH_LIMIT,
|
|
987
|
-
{ filterId, mailboxIds },
|
|
988
|
-
);
|
|
989
|
-
if (match.rejected) return match;
|
|
990
|
-
|
|
991
|
-
const { filter, predicate } = loaded;
|
|
992
|
-
const result: FilterBackApplyResult = {
|
|
993
|
-
...nothing,
|
|
994
|
-
matched: match.messageIds.length,
|
|
995
|
-
semanticUnavailable: match.semanticUnavailable,
|
|
996
|
-
};
|
|
997
|
-
for (const messageId of match.messageIds) {
|
|
998
|
-
const message = await filterMessageOf(client, accountConfigId, messageId);
|
|
999
|
-
if (!message) continue;
|
|
1000
|
-
const decision = await pipeline.evaluate(
|
|
1001
|
-
accountConfigId,
|
|
1002
|
-
messageId,
|
|
1003
|
-
message,
|
|
1004
|
-
);
|
|
1005
|
-
const move = (decision.move?.filterId ?? filterId) === filterId;
|
|
1006
|
-
if (!move && filter.actionLabelId === NO_ACTION) continue;
|
|
1007
|
-
const outcome = await applyOrganize(
|
|
1008
|
-
{ client, moveService },
|
|
1009
|
-
accountConfigId,
|
|
1010
|
-
[messageId],
|
|
1011
|
-
{
|
|
1012
|
-
...predicate,
|
|
1013
|
-
actionMailboxId: move ? filter.actionMailboxId : NO_ACTION,
|
|
1014
|
-
},
|
|
1015
|
-
);
|
|
1016
|
-
result.applied += outcome.applied;
|
|
1017
|
-
result.failed += outcome.failed;
|
|
1018
|
-
}
|
|
1019
|
-
return result;
|
|
1020
|
-
};
|
|
1021
|
-
|
|
1022
|
-
/** Whether a job row runs a standing filter rather than its own snapshot. */
|
|
1023
|
-
export const isFilterJob = (
|
|
1024
|
-
job: Pick<OrganizeJobRequestItem, "filterId">,
|
|
1025
|
-
): boolean => job.filterId !== NO_ACTION;
|