fix(bindx-client): union nested relation selections across sibling creates (#70) - #71
Closed
matej21 wants to merge 3 commits into
Closed
fix(bindx-client): union nested relation selections across sibling creates (#70)#71matej21 wants to merge 3 commits into
matej21 wants to merge 3 commits into
Conversation
matej21
marked this pull request as draft
August 19, 2026 13:12
matej21
marked this pull request as ready for review
August 19, 2026 13:25
…eates (#70) buildSelectionFromOps unioned scalar fields across sibling create/update ops but kept nested relations in a Map keyed by field name, so the last sibling's shape won. Two blocks whose nested `button` creates carried different fields emitted a node selection covering only one of them, so the response could not be content-matched back to the other sibling: its nested creates kept their temp IDs while still being committed as existing on the server, and the next edit went out as an update keyed by `__temp_...`, which the API rejects. Nested payloads are now accumulated per field name across every sibling and fed back through buildSelectionFromOps, so the union is recursive by construction for both has-one and has-many, nested-in-nested included. The walker is rebuilt around one primitive (buildSelectionFromDataObjects) and is now cast-free; a latent Object.entries(null) crash on a present-but-null `data` is guarded by the new isRecord type guard. The selection tests live under tests/unit/ rather than tests/bindx-client/ because only tests/unit, tests/react and tests/cases are in the CI script. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
…70) Widening the mutation node selection also widened the content matcher: the selection is its input, so selecting a nested relation that last-wins previously dropped makes isCreateDataMatchingNode recurse into a subtree it used to skip. Any scalar the server does not echo back byte-identically — an ordinary datetime normalisation is enough — then failed the match for the entire parent create op, and the greedy loop discarded that sibling and every entity nested under it. A one-entity temp-ID leak became a three-entity one, silently, with success: true. extractNestedResultsFromNode's greedy first-fit loop is replaced by a pairing pass: first pair every op that has exactly one candidate row, removing that row and looping, since consuming a row often makes another op unique; then fall back to first-fit for ops that remain ambiguous; then, if exactly one op and one row are left unpaired, pair them. The uniqueness pass is what makes elimination sound. Bare elimination on top of the greedy loop would have widened a pre-existing bug: a subset payload steals its sibling's row today and the sibling ends up unmapped, but with plain elimination the sibling would instead be mis-mapped onto the subset's row — silent cross-wiring rather than a leak. Pairing the precise payload first removes that precondition, which also repairs the existing bug. isCreateDataMatchingNode is untouched: strict comparison is still the evidence, it is just no longer the sole arbiter. The matcher must not treat "cannot identify" as "discard". Elimination is deliberately capped at one op and one row; with two simultaneously unmatchable siblings the entities keep their temp IDs rather than being guessed at, which a test pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
matej21
force-pushed
the
fix/nested-create-selection-union
branch
from
August 20, 2026 09:33
8bce1b4 to
ec8007c
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.
Refs #70 — delivers the issue's expected behaviour 1 robustly. Expected behaviour 2 is still not implemented; see "What remains" before closing the issue.
Problem
buildSelectionFromOpsunioned scalar fields across sibling create/update ops but kept nested relations in aMapkeyed by field name — so the last sibling's shape won:With sibling A's
button.create = { label, modalTitle }and sibling B'sbutton.create = { label, link: { create } }, the emitted node selection wasbutton { id label link { … } }— nomodalTitle. The response therefore could not be content-matched back to sibling A. Its nested creates kept their__temp_…IDs while still being committed as existing on the server, so the next edit went out asupdateButton(by: {id: "__temp_…"})and the API answeredExpected type "UUID". Every subsequent save of the page failed the same way until a full reload.Change
Nested payloads are accumulated per field name across every sibling and fed back through
buildSelectionFromOps, so the union is recursive by construction — has-one and has-many, nested-in-nested included. The walker is rebuilt around one primitive (buildSelectionFromDataObjects).Incidental improvements from the rewrite:
isRecordtype guard replaces everyas Record<string, unknown>);Object.entries(null)crash is fixed — the old('data' in update ? update['data'] : update)reachedObject.entrieswith a present-but-nulldata;idis no longer emitted twice when the data already carries one.Scope
Only the selection-union defect. The issue also proposes refusing to commit an entity whose temp ID could not be reconciled. That was implemented and then reverted, because
tests/nestedHasManyCreate.test.tsdocuments an unreconciled create as an expected steady state under row-level ACL ("The other review (missing from response due to ACL) keeps its temp ID"). Leaving such an entity uncommitted would re-emit itscreateon the next save — silent duplicate rows. A client-side guard that refuses the mutation was also tried and reverted: it aborts the whole batch, and sinceContemberAdapterhas nopersistTransactionthe sequential fallback means one unreconciled create would block every unrelated edit in the app, with no recovery short of a reload. That is a separate decision and deserves its own issue.Tests
tests/unit/persistence/nestedCreateTempIdLeak.test.ts— the reporter's reproducer, cherry-picked unmodified. Fails onmain, passes here.tests/unit/persistence/mutationSelectionUnion.test.ts— sibling union, recursive union through a nested has-many, andiddedup. Placed undertests/unit/rather thantests/bindx-client/because the CItestscript only coverstests/unit,tests/reactandtests/cases.Verified:
tests/unit/persistence+tests/nestedHasManyCreate.test.tsgreen (13 pass on the targeted set), full CI suite 1545 pass,tests/bindx-client38 pass, typecheck clean.Not verified
No live API was exercised — both new tests use mock adapters, and the reproducer derives its mock response from the function under test, so it proves "if the server echoes what we selected, matching works" but cannot see over-selection or server-applied defaults. A second-order effect worth knowing: newly selecting nested has-one creates makes
isCreateDataMatchingNoderecurse where it previously skipped, so a server-side transform of a nested create's scalar would now fail a match that previously passed vacuously.🤖 Generated with Claude Code
https://claude.ai/code/session_01GiniFfaE4gb5EuQpQ3Ncee
Second commit: pair by elimination, not greedy first-fit
An independent review reproduced a regression in the first commit, and fixing it turned out to matter more than the union itself.
The problem. The node selection is also the content matcher's input. Selecting a nested relation that last-wins previously dropped makes
isCreateDataMatchingNoderecurse into a subtree it used to skip, so any scalar the server does not echo back byte-identically — an ordinary datetime normalisation is enough — failed the match for the entire parent create op, and the greedy loop discarded that sibling and everything nested under it.The fix.
extractNestedResultsFromNode's greedy first-fit loop becomes a pairing pass:Step 1 is what makes step 3 sound. Bare elimination on top of the greedy loop would have widened a pre-existing bug — a subset payload steals its sibling's row today and the sibling ends up unmapped, but with plain elimination the sibling would be mis-mapped onto the subset's row, cross-wiring two rows instead of leaking one. Pairing the precise payload first removes that precondition.
isCreateDataMatchingNodeis untouched. Strict comparison is still the evidence; it is just no longer the sole arbiter. The principle: the matcher must not treat "cannot identify" as "discard".Measured, same shape as the reported regression (server normalises a nested
publishedAt):server-2server-3nullserver-4server-5nullnullnullserver-5server-6server-2server-3server-4server-5server-6Better than the pre-#70 baseline:
linkAwas never resolvable before, becauselinkwas not in the selection at all.Bonus, and it was not optional. This also repairs the pre-existing bug the review found independently: when one sibling's create payload is a strict subset of another's and the server returns the node array reordered, sibling A was mapped to sibling B's server row, so A's later edits wrote to B's row. The uniqueness pass is exactly what prevents that, so the repair falls out of the safety requirement rather than being a separate change.
What remains
__temp_id #70 on that basis.Verification
Branch verified in isolation (its own
bun install, without the other in-flight units): typecheck clean,bun run test1536 pass / 0 fail,tests/bindx-client38 pass.Four new tests in
tests/unit/persistence/nestedCreateNormalisedScalar.test.ts, each checked against a reverted matcher to confirm it bites: the normalisation shape fails without the fix; the subset+reorder shape fails without it; the two-unmatchable and indistinguishable-siblings cases are boundary pins that pass either way.