|
| 1 | +--- |
| 2 | +"@objectstack/lint": minor |
| 3 | +"@objectstack/spec": patch |
| 4 | +--- |
| 5 | + |
| 6 | +feat(lint): a flow `update_record` node writing an undeclared field gates the build (#4271) |
| 7 | + |
| 8 | +The write-set family #4305 (hooks) and #4344 (actions) opened had a third |
| 9 | +surface, and it was the one the docs had spent the longest recommending as the |
| 10 | +safe alternative to the other two. A flow `update_record` node whose |
| 11 | +`config.fields` names a field the target object never declares was caught by |
| 12 | +**nothing**: `validate-readonly-flow-writes.ts` walks that exact map and |
| 13 | +explicitly stepped over the unknown key (`if (!meta) continue; // a |
| 14 | +form/field-layout lint concern` — a referral to a rule that does not check |
| 15 | +writes), and `validate-flow-template-paths.ts` checks the `{record.<path>}` |
| 16 | +READ tokens interpolated into node config, never the write-side key. So the |
| 17 | +surface `hook-bodies.mdx` pointed authors at — "prefer a flow `update_record` |
| 18 | +node, whose structural `fields` config is checked" — was the least checked of |
| 19 | +the three. |
| 20 | + |
| 21 | +**New rule — `flow-node-write-unknown-field`, and it is an `error`.** Wired into |
| 22 | +`REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` report |
| 23 | +it at once (one more place than the hand-wired readonly rule next door reaches). |
| 24 | + |
| 25 | +**Why it gates where its two siblings advise.** The hook and action rules are |
| 26 | +advisory because they PARSE JavaScript: the finding is only as good as the |
| 27 | +extractor, and a false positive kills an advisory lint. Nothing here is parsed — |
| 28 | +`config.fields` is a literal map next to a literal `objectName`, the same |
| 29 | +certainty `flow-update-readonly-field` already gates on one config key over. A |
| 30 | +rule that errors on a write the engine *strips* while only warning on a write |
| 31 | +that names no column at all would be incoherent in the same `fields` map. |
| 32 | + |
| 33 | +And the runtime consequence is not the benign "consumer skips the unknown name |
| 34 | +and renders the rest" that keeps `page-field-unknown` / `form-field-unknown` |
| 35 | +advisory. Both halves were measured, not inferred: |
| 36 | + |
| 37 | +- Through the engine, an undeclared key reaches `driver.update` verbatim — the |
| 38 | + flow executor calls the data engine directly, the UPDATE path strips only |
| 39 | + readonly/readonlyWhen, and the SQL driver's `formatInput` / |
| 40 | + `applyWriteColumnMap` pass an unrecognized key straight through (`m[k] ?? k`). |
| 41 | +- On SQLite/knex it becomes `update "deal" set "name" = 'n2', "stagee" = 'won' … |
| 42 | + → no such column: stagee`. The statement is rejected **whole**: `name` — |
| 43 | + spelled correctly, in the same payload — does not land either, and the step |
| 44 | + fails with a driver error naming a column, far from the authoring mistake. |
| 45 | +- On a schemaless datasource nothing rejects it, so the stray key is persisted |
| 46 | + into a column the object never declares, where no schema-driven read returns |
| 47 | + it. |
| 48 | + |
| 49 | +That is the call `validate-searchable-fields` makes for a stale entry and |
| 50 | +`validate-flow-template-paths` makes for a filter-position token: gate when the |
| 51 | +miss breaks or corrupts the operation, advise when it merely narrows the output. |
| 52 | + |
| 53 | +**One field index and one implicit-field set across all three surfaces.** |
| 54 | +`indexObjectFields` and `IMPLICIT_FIELDS` are imported from the hook rule rather |
| 55 | +than copied, so the three rules cannot drift on what is writable without being |
| 56 | +authored — the shape #4330 collapsed one package over. |
| 57 | + |
| 58 | +Every skip exists so the gate only ever fires on a certainty, and each is |
| 59 | +silent: a templated `objectName`, a non-literal `fields` map, an object this |
| 60 | +stack does not define, an object that declares no fields at all (external / |
| 61 | +datasource-introspected schemas, the same skip `validate-searchable-fields` |
| 62 | +takes), and dotted keys (a nested-path write, not a top-level column). `runAs` |
| 63 | +is deliberately NOT consulted, unlike the readonly rule that skips |
| 64 | +`runAs:'system'` — an elevated identity bypasses the readonly strip, but no run |
| 65 | +identity conjures a column. |
| 66 | + |
| 67 | +**Scope is declared as data, not left as silence.** `FLOW_WRITE_NODE_TYPES` |
| 68 | +(today `update_record`) and `FLOW_WRITE_NODE_TYPES_DEFERRED` (`create_record`, |
| 69 | +with its reason) are partition-tested against the CRUD node types that carry a |
| 70 | +`fields` write map — derived behaviourally from the spec's executor-written |
| 71 | +config schemas, not restated — so a node type that grows one later fails that |
| 72 | +test until someone classifies it. |
| 73 | + |
| 74 | +`@objectstack/spec`: `ScriptBodySchema`'s "prefer a flow `update_record` node, |
| 75 | +whose structural `fields` config is error-checked" note now names the rule that |
| 76 | +makes it true. Doc comment only — no schema or generated-artifact change. |
| 77 | + |
| 78 | +Docs: #4355 had just rewritten `automation/hook-bodies.mdx` to record this gap |
| 79 | +honestly — "**Prefer a flow `update_record` node when the write set is fixed — |
| 80 | +but not for *this* check** … writing a field the object never declares is |
| 81 | +currently reported by nothing at all. On that one axis an L2 body is now the |
| 82 | +better-checked surface." That bullet, and the matching note in |
| 83 | +`automation/hooks.mdx`, are the two sentences this change makes false. Both now |
| 84 | +say the axis has flipped back — and why the flow side lands a level *stronger* |
| 85 | +than the body side rather than merely level with it. |
0 commit comments