Skip to content

engine.update() has no declared-field door either — an undeclared key still reaches the driver, after the beforeUpdate hooks have run #8738

Description

@hotlong

Found while implementing #8682 (PR #8737), and deliberately left out of that PR's scope — filing it rather than widening a card that was scoped to the insert path.

What #8737 fixed, and what it did not

#8682 measured the INSERT path: an undeclared write key was refused only at the very end, by the driver, after the defaults, the beforeInsert hooks, owner/creator resolution and an AUTONUMBER had all been produced for a request that was already going to be refused. PR #8737 adds a declared-field door at the top of insert()'s middleware body, so the schema refuses the key first.

update() got no such door. The two write paths sit in the same file and share the shape of the problem.

Evidence — measured vs inferred, stated separately

Measured (on origin/main @ 3508678, and the basis for #8737):

  • Nothing in packages/objectql/src/engine.ts performs any field-existence check on the write payload. A repo-wide read of the write paths found no such guard on either verb; the only unknown-field doors are on the READ path (assertProjectionHasNoDottedPaths and the plain-column filters in find / findOne).
  • mapDataError in packages/rest/src/rest-server.ts says so in its own comment, for both verbs: "this is a last-resort safety net — the validation layer should ideally reject these before they reach the driver (see follow-ups on unknown-field rejection + provenance-aware required checks)". The UPDATE phrasings (column "c" of relation "t" does not exist, table t has no column named c) are handled by that same branch.

Inferred, not measured — I did not run an update-path reproduction, so this needs confirming before anyone implements:

  • an undeclared key on engine.update(...) reaches the driver and is refused there;
  • the beforeUpdate hooks run first, so a hook that stamps a ledger, calls out, or derives a field executes for a request that is then refused.

Why it is milder than the insert half, and why it is still worth doing

There is no autonumber on the update path, so the permanent, user-visible damage that made #8682 urgent — a document-number gap across a rejected request — has no equivalent here. What remains is the hook side effect and the general "declared = enforced" principle (Prime Directive #10): an undeclared key should be refused by the schema, not by the storage engine.

Note the accept-set half of this is not free, and #8737 is the reference for what it costs: adding the insert door turned up 49 failures in the objectql suite alone, every one an accept-set narrowing — 40 of them fixtures whose registry stub carried an empty or incomplete field map, and 24 caused by four sys_metadata stubs missing a column the real object declares. The same triage should be expected here, and #8737's three deliberate no-opinion cases (absent field map, empty field map, id / created_at / updated_at) are the rules to reuse rather than re-derive.

Not affected: the value-logging half

Update operation failed already redacts the bound statement — #8737 applied the same redactBoundStatement to the insert, update and delete loggers, since an UPDATE inlines the caller's values in its set clause exactly as an INSERT does in its values list. Only the ORDERING half is open here.

Suggested landing: packages/objectql/src/engine.ts, reusing undeclaredInsertFieldErrors (rename it if it grows a second caller) with the update path's own placement — before beforeUpdate and before the readonly strips.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions