@remit/backend 0.0.79 → 0.0.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -44,11 +44,11 @@ describe("predicateFromInput (move back-apply accepted)", () => {
|
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// the 4xx actually reaches the wire — not just that the
|
|
50
|
-
//
|
|
51
|
-
// Error would otherwise fall through to a 500 (reader #457).
|
|
47
|
+
// The matcher returns a body-content (HasWords) refusal as a result (see
|
|
48
|
+
// service/organize.test.ts); previewOrganize is the boundary that words it as a
|
|
49
|
+
// BadRequestError. Proving the 4xx actually reaches the wire — not just that the
|
|
50
|
+
// right class is raised — means proving the shared error handler maps it, since
|
|
51
|
+
// a plain Error would otherwise fall through to a 500 (reader #457).
|
|
52
52
|
describe("previewOrganize rejected-rule response (reader #457)", () => {
|
|
53
53
|
it("maps a rejected HasWords clause to a 400 naming the reason, not a 500", async () => {
|
|
54
54
|
const error = new BadRequestError(
|
package/src/handlers/organize.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
OrganizeJobResponse,
|
|
6
6
|
OrganizePreviewResponse,
|
|
7
7
|
} from "@remit/api-openapi-types";
|
|
8
|
-
import { NotFoundError } from "@remit/data-ports/errors";
|
|
8
|
+
import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
|
|
9
9
|
import { logger } from "@remit/logger-lambda";
|
|
10
10
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
11
11
|
import { env } from "expect-env";
|
|
@@ -157,18 +157,19 @@ export const OrganizeOperations: Record<
|
|
|
157
157
|
const client = await getClient();
|
|
158
158
|
await assertAccount(client, accountId, accountConfigId, "read");
|
|
159
159
|
|
|
160
|
-
const
|
|
160
|
+
const result = await matchOrganize(
|
|
161
161
|
buildOrganizeMatchDeps(client),
|
|
162
162
|
accountConfigId,
|
|
163
163
|
predicateFromInput(input),
|
|
164
164
|
ORGANIZE_MATCH_LIMIT,
|
|
165
165
|
);
|
|
166
|
+
if (result.rejected) throw new BadRequestError(result.rejected.message);
|
|
166
167
|
|
|
167
168
|
const response: OrganizePreviewResponse = {
|
|
168
|
-
matchedCount: messageIds.length,
|
|
169
|
-
messageIds,
|
|
169
|
+
matchedCount: result.messageIds.length,
|
|
170
|
+
messageIds: result.messageIds,
|
|
170
171
|
};
|
|
171
|
-
if (semanticUnavailable) response.semanticUnavailable = true;
|
|
172
|
+
if (result.semanticUnavailable) response.semanticUnavailable = true;
|
|
172
173
|
return response;
|
|
173
174
|
},
|
|
174
175
|
};
|
|
@@ -109,6 +109,15 @@ const createInMemoryOutboxRepository = (): IOutboxMessageRepository => {
|
|
|
109
109
|
outboxMessageId: string,
|
|
110
110
|
status: OutboxMessageItem["status"],
|
|
111
111
|
) => repository.update(accountConfigId, outboxMessageId, { status }),
|
|
112
|
+
updateIfStatus: async (
|
|
113
|
+
accountConfigId: string,
|
|
114
|
+
outboxMessageId: string,
|
|
115
|
+
expected: OutboxMessageItem["status"],
|
|
116
|
+
input: UpdateOutboxMessageInput,
|
|
117
|
+
) => {
|
|
118
|
+
if (mustGet(outboxMessageId).status !== expected) return null;
|
|
119
|
+
return repository.update(accountConfigId, outboxMessageId, input);
|
|
120
|
+
},
|
|
112
121
|
markSent: async (
|
|
113
122
|
accountConfigId: string,
|
|
114
123
|
outboxMessageId: string,
|
|
@@ -453,12 +462,17 @@ describe("a send whose enqueue fails (#845.8)", () => {
|
|
|
453
462
|
assert.equal(response.statusCode, 500);
|
|
454
463
|
});
|
|
455
464
|
|
|
456
|
-
it("
|
|
465
|
+
it("settles the row at failed, not stranded at queued and not back at draft", async () => {
|
|
457
466
|
const { outboxMessageId } = await draftAgainstQueue(
|
|
458
467
|
refusingSqsClient().client,
|
|
459
468
|
);
|
|
460
469
|
|
|
461
|
-
|
|
470
|
+
// `queued` is the dead end this suite exists for. `draft` is the other
|
|
471
|
+
// wrong answer: it is inside the SMTP worker's send fence, and an error
|
|
472
|
+
// from SQS does not prove the event was refused — one that landed anyway
|
|
473
|
+
// would send a row the user can still send by hand. `failed` is outside
|
|
474
|
+
// the fence and, since #933, still editable and sendable.
|
|
475
|
+
assert.equal(await statusOf(outboxMessageId), OutboxMessageStatus.failed);
|
|
462
476
|
});
|
|
463
477
|
|
|
464
478
|
it("still lets the user discard what could not be queued", async () => {
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
FilterAnchorItem,
|
|
6
6
|
FilterItem,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import {
|
|
8
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
9
9
|
import { FilterMatchOperator, FilterState } from "@remit/domain-enums";
|
|
10
10
|
import type {
|
|
11
11
|
AnchorPayload,
|
|
@@ -16,9 +16,11 @@ import { createMemoryVectorStore } from "@remit/search-service";
|
|
|
16
16
|
import type { RemitClient } from "./data-client.js";
|
|
17
17
|
import {
|
|
18
18
|
applyOrganize,
|
|
19
|
+
BODY_CONTENT_REJECTION_MESSAGE,
|
|
19
20
|
matchOrganize,
|
|
20
21
|
type OrganizeCandidate,
|
|
21
22
|
type OrganizeMatchDeps,
|
|
23
|
+
type OrganizeMatched,
|
|
22
24
|
type OrganizePredicate,
|
|
23
25
|
} from "./organize.js";
|
|
24
26
|
import {
|
|
@@ -82,6 +84,23 @@ const predicate = (
|
|
|
82
84
|
...over,
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Run the matcher and assert it accepted the predicate, so every test that only
|
|
89
|
+
* cares about the matched set reads the accepted arm directly. A rejection is a
|
|
90
|
+
* result, not a throw (reader #463), so without this the two would be silently
|
|
91
|
+
* interchangeable at the call site.
|
|
92
|
+
*/
|
|
93
|
+
const matchAccepted = async (
|
|
94
|
+
...args: Parameters<typeof matchOrganize>
|
|
95
|
+
): Promise<OrganizeMatched> => {
|
|
96
|
+
const result = await matchOrganize(...args);
|
|
97
|
+
assert.ok(
|
|
98
|
+
result.rejected === null,
|
|
99
|
+
`expected an accepted match, got: ${result.rejected?.message}`,
|
|
100
|
+
);
|
|
101
|
+
return result;
|
|
102
|
+
};
|
|
103
|
+
|
|
85
104
|
/** A standing filter fixture — the "other" filters the precedence check reads. */
|
|
86
105
|
const filterItem = (over: Partial<FilterItem> = {}): FilterItem => ({
|
|
87
106
|
filterId: "filter-other",
|
|
@@ -338,7 +357,7 @@ describe("matchOrganize", () => {
|
|
|
338
357
|
bodyChunk("msg-miss", ORTHOGONAL_VECTOR),
|
|
339
358
|
]);
|
|
340
359
|
|
|
341
|
-
const { messageIds, semanticUnavailable } = await
|
|
360
|
+
const { messageIds, semanticUnavailable } = await matchAccepted(
|
|
342
361
|
matchDeps(store),
|
|
343
362
|
ACCOUNT_CONFIG_ID,
|
|
344
363
|
predicate({ actionLabelId: "lbl-1" }),
|
|
@@ -352,7 +371,7 @@ describe("matchOrganize", () => {
|
|
|
352
371
|
const store = createMemoryVectorStore();
|
|
353
372
|
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
354
373
|
|
|
355
|
-
const { messageIds } = await
|
|
374
|
+
const { messageIds } = await matchAccepted(
|
|
356
375
|
matchDeps(store),
|
|
357
376
|
ACCOUNT_CONFIG_ID,
|
|
358
377
|
{
|
|
@@ -377,7 +396,7 @@ describe("matchOrganize", () => {
|
|
|
377
396
|
}),
|
|
378
397
|
]);
|
|
379
398
|
|
|
380
|
-
const { messageIds } = await
|
|
399
|
+
const { messageIds } = await matchAccepted(
|
|
381
400
|
matchDeps(store),
|
|
382
401
|
ACCOUNT_CONFIG_ID,
|
|
383
402
|
{
|
|
@@ -396,7 +415,7 @@ describe("matchOrganize", () => {
|
|
|
396
415
|
candidate("msg-2", { subject: "Newsletter" }),
|
|
397
416
|
]);
|
|
398
417
|
|
|
399
|
-
const { messageIds } = await
|
|
418
|
+
const { messageIds } = await matchAccepted(deps, ACCOUNT_CONFIG_ID, {
|
|
400
419
|
...predicate(),
|
|
401
420
|
anchorMessageId: "None",
|
|
402
421
|
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
@@ -453,7 +472,7 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
|
|
|
453
472
|
},
|
|
454
473
|
};
|
|
455
474
|
|
|
456
|
-
const { messageIds } = await
|
|
475
|
+
const { messageIds } = await matchAccepted(
|
|
457
476
|
deps,
|
|
458
477
|
ACCOUNT_CONFIG_ID,
|
|
459
478
|
predicate(),
|
|
@@ -478,7 +497,7 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
|
|
|
478
497
|
// No persisted FilterAnchor names this anchorMessageId — an ad hoc "all
|
|
479
498
|
// like these" widen over a bare message selection, never tied to a
|
|
480
499
|
// standing filter.
|
|
481
|
-
const { messageIds } = await
|
|
500
|
+
const { messageIds } = await matchAccepted(
|
|
482
501
|
matchDeps(store),
|
|
483
502
|
ACCOUNT_CONFIG_ID,
|
|
484
503
|
predicate(),
|
|
@@ -507,7 +526,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
507
526
|
candidate("msg-3", { subject: "Table reservation confirmed" }),
|
|
508
527
|
]);
|
|
509
528
|
|
|
510
|
-
const { messageIds, semanticUnavailable } = await
|
|
529
|
+
const { messageIds, semanticUnavailable } = await matchAccepted(
|
|
511
530
|
deps,
|
|
512
531
|
ACCOUNT_CONFIG_ID,
|
|
513
532
|
{
|
|
@@ -526,36 +545,51 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
526
545
|
);
|
|
527
546
|
});
|
|
528
547
|
|
|
529
|
-
it("
|
|
548
|
+
it("refuses a body-content (HasWords) clause as a result rather than matching it against a preview", async () => {
|
|
530
549
|
const deps = vectorlessDeps([
|
|
531
550
|
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
532
551
|
]);
|
|
533
552
|
|
|
534
|
-
await
|
|
535
|
-
()
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
return true;
|
|
553
|
+
const result = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
554
|
+
...predicate(),
|
|
555
|
+
anchorMessageId: "None",
|
|
556
|
+
literalClauses: [{ field: "HasWords", value: "invoice" }],
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
assert.deepEqual(
|
|
560
|
+
result.rejected,
|
|
561
|
+
{
|
|
562
|
+
reason: "BodyContentWithoutVectorPipeline",
|
|
563
|
+
message: BODY_CONTENT_REJECTION_MESSAGE,
|
|
546
564
|
},
|
|
547
565
|
"the vector-free literal path must not silently narrow a body match to a preview",
|
|
548
566
|
);
|
|
549
567
|
assert.equal(deps.semanticUsed(), false);
|
|
550
568
|
});
|
|
551
569
|
|
|
570
|
+
// The refusal is the same whichever way the vector-free path is reached, so
|
|
571
|
+
// an anchored predicate that degrades onto it cannot land in the corpus scan
|
|
572
|
+
// with a body clause it can only mis-evaluate (reader #463).
|
|
573
|
+
it("refuses a body-content clause reached through the degraded widen fallback", async () => {
|
|
574
|
+
const deps = vectorlessDeps([
|
|
575
|
+
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
576
|
+
]);
|
|
577
|
+
|
|
578
|
+
const result = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
579
|
+
...predicate(),
|
|
580
|
+
literalClauses: [{ field: "HasWords", value: "invoice" }],
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
assert.equal(result.rejected?.reason, "BodyContentWithoutVectorPipeline");
|
|
584
|
+
});
|
|
585
|
+
|
|
552
586
|
it("degrades an anchor+clauses widen to the literal matches, flagged, instead of crashing", async () => {
|
|
553
587
|
const deps = vectorlessDeps([
|
|
554
588
|
candidate("msg-1", { subject: "Dinner reservation" }),
|
|
555
589
|
candidate("msg-2", { subject: "Weekly newsletter" }),
|
|
556
590
|
]);
|
|
557
591
|
|
|
558
|
-
const { messageIds, semanticUnavailable } = await
|
|
592
|
+
const { messageIds, semanticUnavailable } = await matchAccepted(
|
|
559
593
|
deps,
|
|
560
594
|
ACCOUNT_CONFIG_ID,
|
|
561
595
|
{
|
|
@@ -571,7 +605,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
571
605
|
it("degrades an anchor-only widen to an empty flagged result instead of crashing", async () => {
|
|
572
606
|
const deps = vectorlessDeps([candidate("msg-1"), candidate("msg-2")]);
|
|
573
607
|
|
|
574
|
-
const { messageIds, semanticUnavailable } = await
|
|
608
|
+
const { messageIds, semanticUnavailable } = await matchAccepted(
|
|
575
609
|
deps,
|
|
576
610
|
ACCOUNT_CONFIG_ID,
|
|
577
611
|
predicate(),
|
|
@@ -623,7 +657,7 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
|
|
|
623
657
|
},
|
|
624
658
|
};
|
|
625
659
|
|
|
626
|
-
const { messageIds, semanticUnavailable } = await
|
|
660
|
+
const { messageIds, semanticUnavailable } = await matchAccepted(
|
|
627
661
|
deps,
|
|
628
662
|
ACCOUNT_CONFIG_ID,
|
|
629
663
|
predicate(),
|
|
@@ -684,12 +718,12 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
684
718
|
|
|
685
719
|
// The preview and the apply share the same matcher — the previewed set is
|
|
686
720
|
// exactly what gets applied.
|
|
687
|
-
const previewed = await
|
|
721
|
+
const previewed = await matchAccepted(
|
|
688
722
|
matchDeps(store),
|
|
689
723
|
ACCOUNT_CONFIG_ID,
|
|
690
724
|
p,
|
|
691
725
|
);
|
|
692
|
-
const applied = await
|
|
726
|
+
const applied = await matchAccepted(matchDeps(store), ACCOUNT_CONFIG_ID, p);
|
|
693
727
|
assert.deepEqual(previewed, applied);
|
|
694
728
|
|
|
695
729
|
const tracked = trackingClient();
|
|
@@ -723,7 +757,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
723
757
|
await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
|
|
724
758
|
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
725
759
|
|
|
726
|
-
const { messageIds: matched } = await
|
|
760
|
+
const { messageIds: matched } = await matchAccepted(
|
|
727
761
|
matchDeps(store),
|
|
728
762
|
ACCOUNT_CONFIG_ID,
|
|
729
763
|
p,
|
|
@@ -749,7 +783,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
749
783
|
]);
|
|
750
784
|
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
751
785
|
|
|
752
|
-
const { messageIds: matched } = await
|
|
786
|
+
const { messageIds: matched } = await matchAccepted(
|
|
753
787
|
matchDeps(store),
|
|
754
788
|
ACCOUNT_CONFIG_ID,
|
|
755
789
|
p,
|
|
@@ -799,7 +833,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
799
833
|
actionMailboxId: "mbox-target",
|
|
800
834
|
});
|
|
801
835
|
|
|
802
|
-
const { messageIds: matched } = await
|
|
836
|
+
const { messageIds: matched } = await matchAccepted(
|
|
803
837
|
matchDeps(store),
|
|
804
838
|
ACCOUNT_CONFIG_ID,
|
|
805
839
|
p,
|
|
@@ -834,7 +868,7 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
834
868
|
await store.upsert(matching.map((id) => bodyChunk(id, ANCHOR_VECTOR)));
|
|
835
869
|
const p = predicate({ actionMailboxId: "mbox-target" });
|
|
836
870
|
|
|
837
|
-
const { messageIds: matched } = await
|
|
871
|
+
const { messageIds: matched } = await matchAccepted(
|
|
838
872
|
matchDeps(store),
|
|
839
873
|
ACCOUNT_CONFIG_ID,
|
|
840
874
|
p,
|
|
@@ -897,7 +931,7 @@ describe("applyOrganize resolves move precedence against current Active filters
|
|
|
897
931
|
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
898
932
|
});
|
|
899
933
|
|
|
900
|
-
const { messageIds: matched } = await
|
|
934
|
+
const { messageIds: matched } = await matchAccepted(
|
|
901
935
|
matchDeps(store),
|
|
902
936
|
ACCOUNT_CONFIG_ID,
|
|
903
937
|
p,
|
|
@@ -946,7 +980,7 @@ describe("applyOrganize resolves move precedence against current Active filters
|
|
|
946
980
|
literalClauses: [{ field: "Subject", value: "reservation" }],
|
|
947
981
|
});
|
|
948
982
|
|
|
949
|
-
const { messageIds: matched } = await
|
|
983
|
+
const { messageIds: matched } = await matchAccepted(
|
|
950
984
|
matchDeps(store),
|
|
951
985
|
ACCOUNT_CONFIG_ID,
|
|
952
986
|
p,
|
|
@@ -998,7 +1032,7 @@ describe("applyOrganize resolves move precedence against current Active filters
|
|
|
998
1032
|
updatedAt: 0,
|
|
999
1033
|
};
|
|
1000
1034
|
|
|
1001
|
-
const { messageIds: matched } = await
|
|
1035
|
+
const { messageIds: matched } = await matchAccepted(
|
|
1002
1036
|
matchDeps(store),
|
|
1003
1037
|
ACCOUNT_CONFIG_ID,
|
|
1004
1038
|
p,
|
|
@@ -1090,7 +1124,7 @@ describe("back-apply repairs a drifted anchor the way index-time matching does (
|
|
|
1090
1124
|
]);
|
|
1091
1125
|
const deps = currentModelDeps(store, [staleAnchor("filter-a")]);
|
|
1092
1126
|
|
|
1093
|
-
const { messageIds } = await
|
|
1127
|
+
const { messageIds } = await matchAccepted(
|
|
1094
1128
|
deps,
|
|
1095
1129
|
ACCOUNT_CONFIG_ID,
|
|
1096
1130
|
predicate(),
|
|
@@ -1126,7 +1160,7 @@ describe("back-apply repairs a drifted anchor the way index-time matching does (
|
|
|
1126
1160
|
});
|
|
1127
1161
|
const drifted = staleAnchor("filter-newer-semantic");
|
|
1128
1162
|
|
|
1129
|
-
const { messageIds: matched } = await
|
|
1163
|
+
const { messageIds: matched } = await matchAccepted(
|
|
1130
1164
|
matchDeps(store),
|
|
1131
1165
|
ACCOUNT_CONFIG_ID,
|
|
1132
1166
|
p,
|
|
@@ -1232,7 +1266,7 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
|
1232
1266
|
candidate("msg-3", {}),
|
|
1233
1267
|
]);
|
|
1234
1268
|
|
|
1235
|
-
const { messageIds } = await
|
|
1269
|
+
const { messageIds } = await matchAccepted(deps, ACCOUNT_CONFIG_ID, {
|
|
1236
1270
|
...predicate(),
|
|
1237
1271
|
anchorMessageId: "None",
|
|
1238
1272
|
literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
|
|
@@ -1248,7 +1282,7 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
|
1248
1282
|
candidate("msg-3", { from: "attacker@github.com.evil.example" }),
|
|
1249
1283
|
]);
|
|
1250
1284
|
|
|
1251
|
-
const { messageIds } = await
|
|
1285
|
+
const { messageIds } = await matchAccepted(deps, ACCOUNT_CONFIG_ID, {
|
|
1252
1286
|
...predicate(),
|
|
1253
1287
|
anchorMessageId: "None",
|
|
1254
1288
|
literalClauses: [{ field: "FromDomain", value: "github.com" }],
|
|
@@ -1270,13 +1304,13 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
|
1270
1304
|
bodyChunk("msg-2", ANCHOR_VECTOR, { listId: "other.list.example" }),
|
|
1271
1305
|
]);
|
|
1272
1306
|
|
|
1273
|
-
const byListId = await
|
|
1307
|
+
const byListId = await matchAccepted(matchDeps(store), ACCOUNT_CONFIG_ID, {
|
|
1274
1308
|
...predicate(),
|
|
1275
1309
|
literalClauses: [{ field: "ListId", value: "actions.github.com" }],
|
|
1276
1310
|
});
|
|
1277
1311
|
assert.deepEqual(byListId.messageIds, ["msg-1"]);
|
|
1278
1312
|
|
|
1279
|
-
const byFromDomain = await
|
|
1313
|
+
const byFromDomain = await matchAccepted(
|
|
1280
1314
|
matchDeps(store),
|
|
1281
1315
|
ACCOUNT_CONFIG_ID,
|
|
1282
1316
|
{
|
|
@@ -1299,8 +1333,8 @@ describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
|
1299
1333
|
literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
|
|
1300
1334
|
});
|
|
1301
1335
|
|
|
1302
|
-
const previewed = await
|
|
1303
|
-
const applied = await
|
|
1336
|
+
const previewed = await matchAccepted(deps, ACCOUNT_CONFIG_ID, p);
|
|
1337
|
+
const applied = await matchAccepted(deps, ACCOUNT_CONFIG_ID, p);
|
|
1304
1338
|
assert.deepEqual(previewed, applied);
|
|
1305
1339
|
assert.deepEqual(previewed.messageIds, ["msg-1", "msg-2"]);
|
|
1306
1340
|
|
package/src/service/organize.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
IFilterAnchorRepository,
|
|
6
6
|
OrganizeJobRequestItem,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import {
|
|
8
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
9
9
|
import { FilterClauseField, FilterState } from "@remit/domain-enums";
|
|
10
10
|
import { logger } from "@remit/logger-lambda";
|
|
11
11
|
import {
|
|
@@ -154,12 +154,32 @@ export interface OrganizeMatchDeps {
|
|
|
154
154
|
filterAnchors: Pick<IFilterAnchorRepository, "listByAccountConfig" | "put">;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Why the matcher refused a predicate. A refusal is an expected outcome of
|
|
159
|
+
* asking for a rule this deployment cannot evaluate, not a fault: the HTTP
|
|
160
|
+
* boundary words it as a 400 and the back-apply worker fails the job row on it,
|
|
161
|
+
* neither of which may treat it as the infrastructure failure a throw would
|
|
162
|
+
* make it (reader #463).
|
|
163
|
+
*/
|
|
164
|
+
export interface OrganizeRejection {
|
|
165
|
+
reason: "BodyContentWithoutVectorPipeline";
|
|
166
|
+
message: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
157
169
|
/** The matched ids plus whether the semantic widen was skipped as unavailable. */
|
|
158
|
-
export interface
|
|
170
|
+
export interface OrganizeMatched {
|
|
171
|
+
rejected: null;
|
|
159
172
|
messageIds: string[];
|
|
160
173
|
semanticUnavailable: boolean;
|
|
161
174
|
}
|
|
162
175
|
|
|
176
|
+
/** The predicate was refused: nothing matched, and nothing may be applied. */
|
|
177
|
+
export interface OrganizeMatchRejected {
|
|
178
|
+
rejected: OrganizeRejection;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type OrganizeMatchResult = OrganizeMatched | OrganizeMatchRejected;
|
|
182
|
+
|
|
163
183
|
const hasAnchor = (predicate: OrganizePredicate): boolean =>
|
|
164
184
|
predicate.anchorMessageId !== NO_ACTION && predicate.anchorMessageId !== "";
|
|
165
185
|
|
|
@@ -334,18 +354,21 @@ const matchSemantic = async (
|
|
|
334
354
|
* happened to be indexed. Body-content matching therefore requires the widen
|
|
335
355
|
* (vector) path — {@link matchSemantic} reconstructs body text from chunk
|
|
336
356
|
* previews there. This guard keeps the two matchers from diverging silently: a
|
|
337
|
-
* body-content clause reaching the vector-free path is
|
|
357
|
+
* body-content clause reaching the vector-free path is refused instead of
|
|
338
358
|
* returning a wrong set.
|
|
339
359
|
*/
|
|
340
|
-
const
|
|
360
|
+
export const BODY_CONTENT_REJECTION_MESSAGE =
|
|
361
|
+
"Organize literal matching cannot evaluate a body-content (HasWords) clause without the vector pipeline — it requires the semantic widen path";
|
|
362
|
+
|
|
363
|
+
const bodyContentRejection = (
|
|
341
364
|
clauses: OrganizePredicate["literalClauses"],
|
|
342
|
-
):
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
365
|
+
): OrganizeRejection | null =>
|
|
366
|
+
clauses.some((clause) => clause.field === FilterClauseField.HasWords)
|
|
367
|
+
? {
|
|
368
|
+
reason: "BodyContentWithoutVectorPipeline",
|
|
369
|
+
message: BODY_CONTENT_REJECTION_MESSAGE,
|
|
370
|
+
}
|
|
371
|
+
: null;
|
|
349
372
|
|
|
350
373
|
/**
|
|
351
374
|
* The literal-only arm: scan a bounded, vector-free slice of the corpus and keep
|
|
@@ -353,7 +376,7 @@ const assertNoBodyContentClause = (
|
|
|
353
376
|
* predicate and as the degraded fallback when a widen is requested on a
|
|
354
377
|
* deployment without the vector pipeline. Serves `From`/`Subject` clauses at
|
|
355
378
|
* full fidelity from the core thread rows; body-content (`HasWords`) clauses are
|
|
356
|
-
*
|
|
379
|
+
* refused before this runs (see {@link bodyContentRejection}).
|
|
357
380
|
*/
|
|
358
381
|
const matchLiteral = async (
|
|
359
382
|
deps: OrganizeMatchDeps,
|
|
@@ -362,7 +385,6 @@ const matchLiteral = async (
|
|
|
362
385
|
limit: number,
|
|
363
386
|
): Promise<string[]> => {
|
|
364
387
|
const clauses = predicate.literalClauses;
|
|
365
|
-
assertNoBodyContentClause(clauses);
|
|
366
388
|
const candidates = await deps.listAccountFilterMessages(
|
|
367
389
|
accountConfigId,
|
|
368
390
|
limit,
|
|
@@ -395,6 +417,9 @@ const matchLiteral = async (
|
|
|
395
417
|
* capability-absence is absorbed exactly as `/search/semantic` absorbs it:
|
|
396
418
|
* the literal matches are returned (empty for an anchor-only predicate) with
|
|
397
419
|
* `semanticUnavailable` set, never a 500. Any other failure propagates.
|
|
420
|
+
* - A predicate the vector-free path cannot evaluate (a body-content clause,
|
|
421
|
+
* see {@link bodyContentRejection}) comes back as a rejection, so a caller
|
|
422
|
+
* can tell a refused rule from a broken deployment (reader #463).
|
|
398
423
|
*/
|
|
399
424
|
export const matchOrganize = async (
|
|
400
425
|
deps: OrganizeMatchDeps,
|
|
@@ -405,17 +430,19 @@ export const matchOrganize = async (
|
|
|
405
430
|
const anchored = hasAnchor(predicate);
|
|
406
431
|
const clauses = predicate.literalClauses;
|
|
407
432
|
if (!anchored && clauses.length === 0) {
|
|
408
|
-
return { messageIds: [], semanticUnavailable: false };
|
|
433
|
+
return { rejected: null, messageIds: [], semanticUnavailable: false };
|
|
409
434
|
}
|
|
410
435
|
|
|
411
436
|
if (!anchored) {
|
|
437
|
+
const rejection = bodyContentRejection(clauses);
|
|
438
|
+
if (rejection) return { rejected: rejection };
|
|
412
439
|
const messageIds = await matchLiteral(
|
|
413
440
|
deps,
|
|
414
441
|
accountConfigId,
|
|
415
442
|
predicate,
|
|
416
443
|
limit,
|
|
417
444
|
);
|
|
418
|
-
return { messageIds, semanticUnavailable: false };
|
|
445
|
+
return { rejected: null, messageIds, semanticUnavailable: false };
|
|
419
446
|
}
|
|
420
447
|
|
|
421
448
|
try {
|
|
@@ -425,22 +452,28 @@ export const matchOrganize = async (
|
|
|
425
452
|
predicate,
|
|
426
453
|
limit,
|
|
427
454
|
);
|
|
428
|
-
return {
|
|
455
|
+
return {
|
|
456
|
+
rejected: null,
|
|
457
|
+
messageIds: semanticIds ?? [],
|
|
458
|
+
semanticUnavailable: false,
|
|
459
|
+
};
|
|
429
460
|
} catch (error) {
|
|
430
461
|
if (!noteSemanticCapabilityAbsence(error)) throw error;
|
|
431
462
|
// The widen cannot run on this deployment. Fall back to the literal
|
|
432
463
|
// clauses over the corpus — nothing when the predicate is anchor-only —
|
|
433
464
|
// and flag the absence so the client can say so.
|
|
434
465
|
if (clauses.length === 0) {
|
|
435
|
-
return { messageIds: [], semanticUnavailable: true };
|
|
466
|
+
return { rejected: null, messageIds: [], semanticUnavailable: true };
|
|
436
467
|
}
|
|
468
|
+
const rejection = bodyContentRejection(clauses);
|
|
469
|
+
if (rejection) return { rejected: rejection };
|
|
437
470
|
const messageIds = await matchLiteral(
|
|
438
471
|
deps,
|
|
439
472
|
accountConfigId,
|
|
440
473
|
predicate,
|
|
441
474
|
limit,
|
|
442
475
|
);
|
|
443
|
-
return { messageIds, semanticUnavailable: true };
|
|
476
|
+
return { rejected: null, messageIds, semanticUnavailable: true };
|
|
444
477
|
}
|
|
445
478
|
};
|
|
446
479
|
|
|
@@ -480,7 +513,7 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
|
480
513
|
* only a truncated `snippet`, and matching
|
|
481
514
|
* a body-content clause against a preview would silently diverge from the live
|
|
482
515
|
* index-time filter's full-body match. Body-content (`HasWords`) clauses are
|
|
483
|
-
* rejected before this is read (see {@link
|
|
516
|
+
* rejected before this is read (see {@link bodyContentRejection}), so the
|
|
484
517
|
* empty `text` is never matched against.
|
|
485
518
|
*/
|
|
486
519
|
const listAccountFilterMessagesFromClient =
|