Skip to content

fix: update() rejects an unpatchable patch key instead of dropping it silently - #44

Merged
btravers merged 1 commit into
mainfrom
fix/update-rejects-unpatchable-keys
Aug 7, 2026
Merged

fix: update() rejects an unpatchable patch key instead of dropping it silently#44
btravers merged 1 commit into
mainfrom
fix/update-rejects-unpatchable-keys

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes #42.

The bug

update() returned Ok while discarding any key it could not apply. Reproduced all three kinds against the current source before touching anything:

patch key before why it vanished
unknown (calculatedAmount on a Rental) Ok, key gone stripped by zod inside make
immutable (id) Ok, unchanged dropped by the frozenKeys filter
computed (shout) Ok, unchanged dropped, then re-derived

The issue reports the first. The other two are the same failure: the caller asked for a change, got a success, and the change never happened.

As #42 notes, the type-level guard does not cover this — PatchOf rejects all three in an object literal, but TypeScript's excess-property check does not survive the shape real adapters have (Record<string, unknown> built conditionally from a request body), which is exactly where the key silently disappeared.

The fix

A patch may carry only keys updateInput accepts. Anything else comes back as an InvalidEntity with the key in path — one issue per offending key, every offender reported, the same rule the invariants follow:

Rental: calculatedAmount: Unknown field for Rental        // path: ["calculatedAmount"]
Org: slug: Immutable field — cannot be patched            // path: ["slug"]
Person: fullName: Computed field — cannot be patched, …   // path: ["fullName"]

The reporter's union design is now expressible: patching a Rental with a co-owner-only field fails and maps to a 422, while the CoOwner variant accepts the identical patch. Verified both, including through the Record<string, unknown> adapter path.

make is deliberately unchanged — it still ignores extra keys so a stored row carrying computed columns round-trips. That asymmetry is now stated in the reference: rehydrating data and patching it are different acts, one heals what is already written, the other states an intent.

Scope note — this goes past what the issue asked

#42 asks for unknown keys. I extended it to immutable and computed keys because the reasoning applies verbatim, and two tests previously pinned that silent drop (crud.spec.ts, computed.spec.ts); both are rewritten to pin rejection. The guarantee those tests protected — an immutable or computed field cannot be changed through a patch — is preserved and strengthened: it still cannot change, and now the caller is told. Happy to narrow this to unknown-keys-only if you would rather keep the drop for the other two.

Breaking

Code relying on the drop breaks, most plausibly update(someWholeOutputObject). No documented or example usage does this — every one passes a narrow partial — and the changeset documents the migration (patch only what you mean to change, or narrow with updateInput.parse(body) first).

Test plan

  • Four new pins written failing first: unknown key, immutable key, computed key, and all-offenders-at-once
  • New guard that make stays lenient about extra keys, so the asymmetry cannot regress
  • format --check · lint · typecheck (all six targets incl. the 5.9.3 consumer pass) · test (161 package + 22 example) · knip · build — green in CI order
  • Docs build clean; reference, declaration table and tutorial updated where they described the old drop

🤖 Generated with Claude Code

`update()` returned `Ok` while discarding any key it could not apply — a
key that is immutable, computed, or not a field of the entity at all.
The caller asked for a change, got a success, and the change never
happened.

The patch type already excluded all three, but TypeScript's
excess-property check only fires on object literals, so the common
adapter shape — building a patch as a `Record<string, unknown>` from a
request body — evaded it entirely and the key vanished into a passing
`Result`.

A rejected key now comes back as an `InvalidEntity` carrying that key in
`path`, one issue per offending key, so an adapter maps it to a 422
naming the field. Every offender reports, the same rule the invariants
follow.

`make` stays lenient by design: it still ignores extra keys, so a stored
row carrying computed columns round-trips. Rehydrating data and patching
it are different acts — one heals what is already written, the other
states an intent.

Closes #42

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 7, 2026 23:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Entity.update(patch) so it no longer returns Ok while silently discarding patch keys it can’t apply (unknown fields, immutable fields, or computed fields). Instead, update() now rejects such patches with an InvalidEntity containing one issue per offending key (with path: [key]), while keeping make() intentionally lenient about extra keys for stored-row rehydration.

Changes:

  • Add runtime validation in update() to reject unknown / immutable / computed patch keys and return an InvalidEntity listing all offending keys.
  • Update and expand tests to pin the new rejection behavior (including “all offenders at once”) and to ensure make() remains lenient about extra keys.
  • Update tutorial/reference docs and add a changeset documenting the (intentional) breaking behavior change.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/entity/src/entity.ts Adds unpatchable() key classification and makes update() reject unpatchable patch keys with per-key issues.
packages/entity/src/crud.spec.ts Rewrites/extends tests to assert update() rejects immutable + unknown keys and reports all offending keys.
packages/entity/src/computed.spec.ts Updates computed-field patch test to assert rejection and adds a pin that make() still ignores extra keys.
docs/tutorial/getting-started.md Updates tutorial wording to reflect update() rejecting immutable keys at runtime.
docs/reference/errors.md Documents the new “rejected patch key” InvalidEntity case and its per-key path.
docs/reference/entry-points.md Clarifies update() strictness vs make() leniency and why runtime checks exist beyond TS excess-property checks.
docs/reference/declaration.md Updates the options table to reflect rejection (not silent dropping) for immutable keys in update().
.changeset/olive-hounds-search.md Adds a minor-version changeset describing the behavior change and migration guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@btravers
btravers merged commit c4a9f38 into main Aug 7, 2026
14 checks passed
@btravers
btravers deleted the fix/update-rejects-unpatchable-keys branch August 7, 2026 23:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

update() silently strips unknown patch keys and returns Ok

2 participants