Skip to content

Commit ec6fad8

Browse files
os-zhuangclaude
andauthored
feat(lint): multi: true 且无 filter 的 delete/update 在 authoring 期告警(#5482) (#5663)
* feat(lint): warn on a `multi: true` delete/update bounded by nothing (#5482) `config: { objectName: 'lead', multi: true }` with no `filter` is a WHOLE-OBJECT write: the executor forwards `where: {}` plus the bulk intent, the engine classifies it as a legal `multi` call, and it lands on `deleteMany`/`updateMany` with no predicate. Reachable only since #5393 gave these nodes a bulk declaration, and silent ever since — the author's only feedback was the step's `acted` count, after the rows were gone. `flow-multi-write-unfiltered` says it at authoring time. A warning, not a gate: the engine's dispatch case-set lists "bulk intent, no predicate" as a valid call, so an explicit purge is an intent the platform grants — which is also why this is not a spec refine. Not a second copy of the #3810 run-time guard: that one refuses a node when a condition the author WROTE interpolated to nothing, and is deliberately keyed on "a written condition is gone" rather than "the filter is empty". The two judge different facts, and the diagnostic names the other one so they are not mistaken for one check. Reported at every nesting depth (#5383/#5635), because a loop-body sweep is the standard janitor shape. Empty combinator arrays are deliberately out of range: #5322/#5134 already ruled their identities, and deciding them here would be a fourth hand-written copy of a producer-side reduction. * refactor(lint): cite the right dispatch authority per write node (#5482) `update_record` has no extracted dispatch module — only delete's is case-set-pinned (`engine-delete-dispatch.ts`). The message used delete's wording for both, which credited update with a case-set that does not exist. Each node type now carries its own authority: delete cites the case-set, update cites the `options.multi` branch (whose refusal fires only when the declaration is absent), and both tests assert their own. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02a8256 commit ec6fad8

4 files changed

Lines changed: 472 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
feat(lint): warn when a `multi: true` delete/update is bounded by nothing — the declared whole-object write (#5482)
6+
7+
A `delete_record` / `update_record` node that declares `multi: true` with no
8+
`filter` (or an empty one) writes the **whole object**: the executor forwards
9+
`where: {}` plus the bulk intent, the data engine classifies that as a legal
10+
`multi` call, and it lands on `driver.deleteMany` / `driver.updateMany` with no
11+
predicate. Every row, every run.
12+
13+
That path only became authorable with #5393, which gave these nodes a bulk
14+
declaration at all — before it the executor never passed `options.multi`, so the
15+
engine refused every predicate write (`Delete requires an ID or
16+
options.multi=true`) and "empty filter + bulk intent" was not a reachable shape.
17+
Since then it has been reachable and **silent**: `filter` is optional, `multi` is
18+
optional, nothing related the two, and the author's only feedback was the step's
19+
`acted` row count — reported after the rows were gone. The common way to get
20+
here is not malice but an omission: declaring the bulk intent and forgetting the
21+
constraint.
22+
23+
`os validate` / `os build` now report `flow-multi-write-unfiltered` for it:
24+
25+
```
26+
flow 'nightly_purge' · node 'purge' (delete_record)
27+
declares `multi: true` with no `filter` key — this is a WHOLE-OBJECT write,
28+
by declaration: every row of 'lead' is deleted on every run. …
29+
```
30+
31+
**A warning, not a gate.** An explicit whole-object purge is something the
32+
platform grants on purpose — the data engine's own dispatch case-set lists "bulk
33+
intent with no predicate at all" as a valid call — so the shape has a legitimate
34+
reading and the run-time path stays open. What was missing was only that the
35+
author hears about it *before* the rows go. For the same reason the fix is not a
36+
schema `refine`: forbidding the shape would delete an intent the engine grants.
37+
38+
Two ways to satisfy the warning: write the constraint you mean into `filter`
39+
(the bounded-bulk reference shape is app-showcase's `showcase_inquiry_purge`), or
40+
confirm that emptying the object is the intent and keep it.
41+
42+
**It does not duplicate the #3810 run-time guard, which judges a different
43+
fact.** That guard refuses a node when a condition the author *wrote*
44+
interpolated to nothing (`{record.ownr}` — a typo — leaving `{}`), and it is
45+
deliberately keyed on "a written condition is gone" rather than on "the filter is
46+
empty", because losing one of two conditions also widens the blast radius. So:
47+
48+
| fact | judged by | when | verdict |
49+
|-------------------------------|--------------------|-----------|---------|
50+
| a written condition vanished | #3810 filter guard | run time | refuse |
51+
| no condition was ever written | this rule | authoring | warn |
52+
53+
A node with `filter: { owner: '{record.ownr}' }` is silent for this rule (a
54+
condition *is* written) and refused by that one; a node with no `filter` at all
55+
is warned about here and — correctly — allowed there. The diagnostic names the
56+
run-time guard so the two are not mistaken for one check.
57+
58+
Reported at every nesting depth, which matters because a scheduled sweep whose
59+
per-item work sits in a `loop` body is the standard janitor shape: a finding
60+
inside a region carries the region scope (`flow 'x' · loop 'sweep' body · node
61+
'purge' (delete_record)`), on the traversal #5383/#5635 added to this family.
62+
63+
Deliberately out of range: an empty **combinator** array (`{ $and: [] }`,
64+
`{ $or: [] }`). #5322/#5134 ruled those and every driver implements the ruling —
65+
empty `$and` is TRUE (so it *is* a whole-object write), empty `$or` is FALSE (so
66+
it matches nothing and must never be warned about) — but telling them apart
67+
requires the boolean-identity reduction, which already exists producer-side in
68+
each driver. A hand-written fourth copy inside a linter is how a scan and a
69+
validator come to answer with two different predicates, so that case is tracked
70+
separately instead.

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ export {
468468
FLOW_DEFAULT_EDGE_WITH_CONDITION,
469469
FLOW_MULTIPLE_DEFAULT_EDGES,
470470
FLOW_INERT_NODE_CONDITION,
471+
FLOW_MULTI_WRITE_UNFILTERED,
471472
} from './lint-flow-patterns.js';
472473

473474
export { lintLivenessProperties } from './lint-liveness-properties.js';

packages/lint/src/lint-flow-patterns.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
FLOW_DEFAULT_EDGE_WITH_CONDITION,
2121
FLOW_MULTIPLE_DEFAULT_EDGES,
2222
FLOW_INERT_NODE_CONDITION,
23+
FLOW_MULTI_WRITE_UNFILTERED,
2324
} from './lint-flow-patterns.js';
2425

2526
const CEL = (source: string) => ({ dialect: 'cel', source });
@@ -995,3 +996,219 @@ describe('#5383 — a recursive config scan does not double-report the container
995996
expect(fnds[0].where).not.toContain("node 'loop_leads'");
996997
});
997998
});
999+
1000+
/**
1001+
* #5482 — the declared WHOLE-OBJECT write: `multi: true` on a
1002+
* `delete_record` / `update_record` with nothing bounding it.
1003+
*
1004+
* Reachable only since #5393 gave these nodes a bulk declaration: before it the
1005+
* executor never passed `options.multi`, the engine refused every predicate
1006+
* write, and "empty filter + bulk" was not an authoring surface at all. Measured
1007+
* on `origin/main` before this rule existed, all four shapes below — top-level
1008+
* delete, empty-object filter, update, and the same node inside a `loop` body —
1009+
* returned `[]` from `lintFlowPatterns`. The only feedback an author got was the
1010+
* step's `acted` row count, after the rows were gone.
1011+
*/
1012+
1013+
/** A janitor flow: one bulk write node, scheduled, correctly `runAs: 'system'`. */
1014+
function purgeFlow(nodeType: string, config: unknown) {
1015+
return {
1016+
flows: [{
1017+
name: 'nightly_purge',
1018+
runAs: 'system',
1019+
nodes: [
1020+
{ id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 3 * * *' } },
1021+
{ id: 'purge', type: nodeType, config },
1022+
],
1023+
edges: [{ id: 'e1', source: 'start', target: 'purge' }],
1024+
}],
1025+
};
1026+
}
1027+
1028+
describe('lintFlowPatterns — unbounded bulk write (#5482)', () => {
1029+
it('flags a delete_record with `multi: true` and NO filter', () => {
1030+
const fnds = lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', multi: true }));
1031+
expect(fnds).toHaveLength(1);
1032+
expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED);
1033+
expect(fnds[0].where).toBe("flow 'nightly_purge' · node 'purge' (delete_record)");
1034+
// Advisory: the engine's dispatch table grants "bulk intent, no predicate"
1035+
// on purpose, so the shape is not provably wrong (severity policy at the top
1036+
// of lint-flow-patterns.ts). `undefined` is how this family spells warning.
1037+
expect(fnds[0].severity).toBeUndefined();
1038+
// Says WHAT it does — the object by name, and that it is every row.
1039+
expect(fnds[0].message).toContain('no `filter` key');
1040+
expect(fnds[0].message).toContain('WHOLE-OBJECT write');
1041+
expect(fnds[0].message).toContain("every row of 'lead' is deleted");
1042+
expect(fnds[0].message).toContain('driver.deleteMany');
1043+
// The authority it cites is the delete dispatch that is actually extracted
1044+
// and case-set-pinned — not a hand-waved "the engine allows it".
1045+
expect(fnds[0].message).toContain('delete-dispatch case-set');
1046+
expect(fnds[0].message).toContain('multi with no predicate at all');
1047+
// …and that the only run-time feedback arrives too late to help.
1048+
expect(fnds[0].message).toMatch(/`acted` row count/);
1049+
expect(fnds[0].message).toMatch(/AFTER the rows are gone/);
1050+
});
1051+
1052+
it('flags an EMPTY filter the same way, and says which of the two it saw', () => {
1053+
const fnds = lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: {}, multi: true }));
1054+
expect(fnds).toHaveLength(1);
1055+
expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED);
1056+
expect(fnds[0].message).toContain('an EMPTY `filter`');
1057+
expect(fnds[0].message).not.toContain('no `filter` key');
1058+
});
1059+
1060+
it('flags an update_record too, in the words of an overwrite', () => {
1061+
const fnds = lintFlowPatterns(
1062+
purgeFlow('update_record', { objectName: 'lead', fields: { status: 'stale' }, multi: true }),
1063+
);
1064+
expect(fnds).toHaveLength(1);
1065+
expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED);
1066+
expect(fnds[0].where).toBe("flow 'nightly_purge' · node 'purge' (update_record)");
1067+
expect(fnds[0].message).toContain("every row of 'lead' is overwritten");
1068+
expect(fnds[0].message).toContain('driver.updateMany');
1069+
// Update has no extracted dispatch module, so the message cites the branch
1070+
// itself rather than borrowing delete's case-set.
1071+
expect(fnds[0].message).toContain('bulk branch on `options.multi`');
1072+
expect(fnds[0].message).not.toContain('delete-dispatch case-set');
1073+
});
1074+
1075+
it('names the #3810 run-time guard and says the two judge DIFFERENT facts', () => {
1076+
const [f] = lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', multi: true }));
1077+
// Cross-naming, not duplication: the run-time guard refuses "a condition you
1078+
// WROTE is gone"; this rule warns "no condition was ever written".
1079+
expect(f.hint).toContain('#3810');
1080+
expect(f.hint).toMatch(/REFUSES this node at run time/);
1081+
expect(f.hint).toMatch(/a written condition is gone/);
1082+
expect(f.hint).toMatch(/the filter is empty/);
1083+
// Both ways out are offered, and the run-time path is explicitly NOT closed.
1084+
expect(f.hint).toMatch(/Write the constraint you mean/);
1085+
expect(f.hint).toMatch(/warning, not a gate/);
1086+
expect(f.hint).toContain('showcase_inquiry_purge');
1087+
});
1088+
1089+
describe('does NOT flag (false-positive guards)', () => {
1090+
it('a bulk write BOUNDED by a filter — the showcase purge shape', () => {
1091+
expect(
1092+
lintFlowPatterns(purgeFlow('delete_record', {
1093+
objectName: 'showcase_inquiry', filter: { status: 'closed' }, multi: true,
1094+
})),
1095+
).toHaveLength(0);
1096+
});
1097+
1098+
it('a filter whose only condition is a TEMPLATE — that is #3810\'s fact, at run time', () => {
1099+
// `{record.ownr}` (a typo) interpolates to nothing and the run-time guard
1100+
// REFUSES the node. At authoring time the condition is written, so warning
1101+
// "nothing bounds this" here would be false — and would put two diagnostics
1102+
// on one defect, one of them wrong about what the author did.
1103+
expect(
1104+
lintFlowPatterns(purgeFlow('delete_record', {
1105+
objectName: 'lead', filter: { owner: '{record.ownr}' }, multi: true,
1106+
})),
1107+
).toHaveLength(0);
1108+
});
1109+
1110+
it('no `multi` at all — the engine refuses that call BY NAME already', () => {
1111+
// `Delete requires an ID or options.multi=true`. Nothing silent to warn
1112+
// about, and #5482 is scoped to the declared-bulk shape.
1113+
expect(lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead' }))).toHaveLength(0);
1114+
expect(lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: {} }))).toHaveLength(0);
1115+
});
1116+
1117+
it('`multi: false` — the declaration says the opposite', () => {
1118+
expect(lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', multi: false }))).toHaveLength(0);
1119+
});
1120+
1121+
it('`multi: \'true\'` (a string) — the schema refuses the node, so it cannot run', () => {
1122+
// The executor tests `cfg.multi === true` and the schema types the key
1123+
// `z.boolean()`; a string is a parse refusal, not declared bulk intent.
1124+
expect(lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', multi: 'true' }))).toHaveLength(0);
1125+
});
1126+
1127+
it('a node type that carries no `multi` declaration', () => {
1128+
// `get_record` does not write and has no bulk intent; `create_record` has
1129+
// neither `filter` nor `multi`. A stray key there is the schema's business.
1130+
expect(lintFlowPatterns(purgeFlow('get_record', { objectName: 'lead', multi: true }))).toHaveLength(0);
1131+
expect(lintFlowPatterns(purgeFlow('create_record', { objectName: 'lead', multi: true }))).toHaveLength(0);
1132+
});
1133+
1134+
it('a non-object `filter` — refused by name at execute time, so no run to describe', () => {
1135+
expect(
1136+
lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: 'status = closed', multi: true })),
1137+
).toHaveLength(0);
1138+
});
1139+
1140+
it('an empty COMBINATOR array — deliberately out of range, both directions', () => {
1141+
// #5322/#5134 ruled these and every driver implements the ruling: `$and: []`
1142+
// is TRUE (this one IS a whole-object write and goes unwarned — filed as a
1143+
// follow-up), `$or: []` is FALSE (matches nothing — warning about it would
1144+
// be a false alarm). Telling them apart needs the identity REDUCTION, which
1145+
// already exists three times producer-side; a fourth hand-written copy in a
1146+
// linter is the divergence `engine-delete-dispatch.ts` exists to prevent.
1147+
expect(
1148+
lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: { $and: [] }, multi: true })),
1149+
).toHaveLength(0);
1150+
expect(
1151+
lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', filter: { $or: [] }, multi: true })),
1152+
).toHaveLength(0);
1153+
});
1154+
});
1155+
1156+
/**
1157+
* The rule's main habitat. A scheduled sweep whose per-item work sits in a
1158+
* `loop` body is the standard shape for a janitor flow, so a rule that only
1159+
* saw top-level nodes would miss the case it was written for — the #5383/#5635
1160+
* blind spot, in the exact family that closed it.
1161+
*/
1162+
describe('inside a nested region (#5383 / #5635)', () => {
1163+
it('flags a loop-body sweep, scoped to the region, exactly once', () => {
1164+
const fnds = lintFlowPatterns(loopBodyFlow({
1165+
nodes: [
1166+
{ id: 'sweep', type: 'delete_record', config: { objectName: 'campaign_member', multi: true } },
1167+
],
1168+
edges: [],
1169+
}));
1170+
expect(fnds).toHaveLength(1);
1171+
expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED);
1172+
expect(fnds[0].where).toBe(
1173+
"flow 'campaign_enrollment' · loop 'loop_leads' body · node 'sweep' (delete_record)",
1174+
);
1175+
// Not attributed to the enclosing container: the `loop`'s own config
1176+
// CONTAINS the body, but this rule reads named keys (`multi`, `filter`) off
1177+
// each node, and a `loop` declares neither — so there is no second copy.
1178+
expect(fnds[0].where).not.toContain("node 'loop_leads'");
1179+
expect(fnds[0].message).toContain("every row of 'campaign_member' is deleted");
1180+
});
1181+
1182+
it('flags an update_record two regions deep', () => {
1183+
const fnds = lintFlowPatterns(loopBodyFlow({
1184+
nodes: [{
1185+
id: 'loop_touchpoints', type: 'loop', label: 'Loop Touchpoints',
1186+
config: {
1187+
collection: '{lead.touchpoints}', itemVar: 'tp',
1188+
body: {
1189+
nodes: [{ id: 'reset', type: 'update_record', config: { objectName: 'touchpoint', fields: { done: false }, multi: true } }],
1190+
edges: [],
1191+
},
1192+
},
1193+
}],
1194+
edges: [],
1195+
}));
1196+
expect(fnds).toHaveLength(1);
1197+
expect(fnds[0].rule).toBe(FLOW_MULTI_WRITE_UNFILTERED);
1198+
expect(fnds[0].where).toBe(
1199+
"flow 'campaign_enrollment' · loop 'loop_leads' body → loop 'loop_touchpoints' body · " +
1200+
"node 'reset' (update_record)",
1201+
);
1202+
});
1203+
1204+
it('leaves a BOUNDED loop-body sweep alone', () => {
1205+
expect(lintFlowPatterns(loopBodyFlow({
1206+
nodes: [{
1207+
id: 'sweep', type: 'delete_record',
1208+
config: { objectName: 'campaign_member', filter: { lead_id: '{lead.id}' }, multi: true },
1209+
}],
1210+
edges: [],
1211+
}))).toHaveLength(0);
1212+
});
1213+
});
1214+
});

0 commit comments

Comments
 (0)