fix: make add-produced fields immutable - #4
Merged
Conversation
btravers
force-pushed
the
fix/add-fields-immutable
branch
from
August 6, 2026 19:16
f7a2a5e to
002b4b9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/entity/src/entity.ts:78
- The comment says freezing added fields is "the only answer that keeps a computed field consistent with its source", but this PR’s intended behavior is to pin added fields (not recompute them) — so when a source field is updated, the added field may become stale relative to it. Reword to avoid implying post-update consistency with the current source value.
* `add` contributed. An added field is *implicitly* immutable — `add`
* reads the **encoded** object, and `update` only ever holds the decoded
* one, which no longer carries an omitted source field like `secret`, so
* there is nothing to recompute from. Freezing them is the only answer
* that keeps a computed field consistent with its source; see `PatchOf`.
packages/entity/src/decoded.spec.ts:76
- This comment claims the
slug/slugUpperpair is "never internally contradictory", but the test intentionally assertsslug === "beta"whileslugUpper === "ACME". Since the added field is pinned (not recomputed), it can become stale when its source field is updated; the comment should reflect that behavior.
// `slugUpper` is carried over, not recomputed — the encoded object `add`
// reads from is gone by `update` time. It stays pinned to the value the
// entity was decoded with, so the pair is never internally contradictory in
// the way a silently stale recomputation would be.
`decoded.add` fields were part of `updateInput` and of the `Patch` type, so a caller could patch a derived field to a value its own source contradicts, and `update()` never re-ran `addSpec.from`, so a patch to a source field left the computed field stale. Recomputation is not available: `add` reads the encoded object, and by `update()` time only the decoded one remains, without the omitted source field. Added keys are therefore excluded from `UpdateInputShapeOf` and `PatchOf`, from the runtime `updateInput`, and from `update()`'s applied patch.
btravers
force-pushed
the
fix/add-fields-immutable
branch
from
August 6, 2026 20:33
002b4b9 to
0b9a39a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
decoded.adddeclares computed fields. Two defects let a caller desynchroniseone from the source it is derived from. Both were reproduced against
mainbefore the fix.
Fixture:
A. Computed fields went stale on
updateupdate()callsthis.encode()thenCtor.make(applied);addSpec.fromwasnever re-run.
slugslugUpper"beta""ACME"(stale)"beta""ACME"(pinned, by design — see below)B. Computed fields were directly patchable
updateInputwas built fromdecoded, which includes the added fields, soObject.keys(Org.updateInput.shape)was['id', 'slug', 'slugUpper']and thepublic API accepted a patch that contradicted the source outright.
org.update({ slugUpper: "LIES" }):slugslugUpper"acme""LIES""acme""ACME"(patch key dropped)Object.keys(Org.updateInput.shape)is now['id', 'slug'], and patching anadded field is a compile error.
Why immutability rather than recomputation
Recomputation was considered and rejected: it cannot be implemented correctly.
add's function takes the encoded object, andupdate()only ever holdsthe decoded one — which, for the motivating case, no longer carries the source
field at all. In the package's own
ApiKeyexample,fingerprintis computedfrom
secret, andsecretisdecoded.omit-ed; byupdate()time it isgone. This is the same encoded/decoded asymmetry the README already documents
under "
decode(x.encode())does not round-trip".So a recomputing
update()would either fail for every entity whose computedfield reads an omitted source, or silently recompute from a partial input and
produce a different wrong answer. Given a value that drifts out of step with
its source versus one a caller can contradict, the package now offers neither:
an
add-produced field is implicitly immutable, whether or notimmutablenames it, and a derived value changes only by decoding a fresh encoded payload.
Changes
types.ts—PatchOfandUpdateInputShapeOfexcludekeyof AalongsideI[number].entity.ts— a singlefrozenKeyslist (declaredimmutableplusadd'skeys) drives both the
updateInputomit mask andupdate()'s runtimedrop-list, mirroring how the latter already defended against a smuggled
immutable field.
decoded.spec.ts— three tests: an added field is absent fromupdateInput.shape;updateon a source field leaves the entityconsistent; a smuggled added field is dropped at runtime.
entity.test-d.ts—@ts-expect-errorpinning that patching an added fieldand reading it off
updateInput.shapeare both compile errors.types.test-d.ts—PatchOfdrops an added field not named inimmutable.contract.spec.ts— theApiKeyfixture gains a mutablelabelfield soupdateInputstill exercises a non-empty update request schema now thatfingerprinthas left it.Compatibility
Type-level narrowing of
Patch/updateInput— code that patched an addedfield stops compiling (it was already producing inconsistent data). Released as
a
minor.