fix(bindx): unite the occurrences of an entity within one server response - #133
Conversation
…y overwriting the wider one
…of replacing it A query can reach one entity through several paths, each with its own sub-selection of a relation, and every path refreshes the entity's snapshot. refreshServerData assigned each incoming field wholesale, so the occurrence read last with a narrower sub-selection dropped the fields a wider one had fetched, and they read back as undefined. The same happened when two roots loaded one entity with different selections. refreshServerData now takes the selection the data was read with and merges embedded relations into the stored values: a has-many takes its membership and order from the read and merges items by id, a has-one merges while it points to the same entity and is replaced otherwise. Scalars, including object-valued JSON columns, are still replaced; the selection is what tells them apart. Without a selection nothing changes. Closes #123 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
…napshot
The embedded copy of a relation is written only when its parent is read,
so after a persist, or a read of the related entity through another
path, it lags behind the related entity's snapshot. Filling the keys a
narrower read did not select from that copy pushed the stale values back
into the related snapshot, in data and server baseline alike, and the
entity looked clean with the old value.
A kept key now takes its value from the related entity's own snapshot
when the store has one, and from the embedded copy only for an entity
nothing has materialized yet. Existing related snapshots therefore end up
exactly as they did before the merge existed; only the keys they lack
are filled.
Also:
- connections ({ edges: [{ node }] }) merge their nodes by id
- the non-enumerable totalCount of a paginated has-many survives a merge
- a merge that keeps nothing returns the incoming value itself, and items
are matched by position before an id index is built
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
Review round 2: the fill rule needs a recency model, and adding one is a store design changeI'm stopping here rather than patching. Both findings are right, and a clean fix changes how the store tracks where a server value came from. That is a maintainer call. Below are the analysis and the options. What is wrong at 432f5d91. The fixed precedence is wrong in one of the two directions. When a narrower read leaves a key out, the fill has two candidate copies:
Neither is always newer.
Repro for round 2 (a root that selects
On 2. The lookup is by id alone ( Why the existing propagation state is not enough
OptionsA. Per-field server read clock (recommended)
B. Normalize on arrival (eager propagation)
C. Don't fill; hide what the read did not carry
RecommendationA, as its own PR that adds the clock, with #133 rebased onto it. It keeps the lazy propagation and its performance profile, and it gives one rule that is testable in both directions. B is simpler to reason about. If you would rather normalize on arrival, it is the better long-term model, but it should be measured against the fan-out concerns in #86 first. I have not pushed anything for round 2. The branch stays at 432f5d9 and is not ready to merge: it fixes #123 and the persist case, but it regresses the refetch case above. Once a direction is chosen, these tests go in alongside it, each in both read orders:
|
…onse Replaces the cross-read merge of the previous commits. Merging a narrower read into what the store already held needs to know which copy is newer: the related entity's snapshot after a persist or a read through another path, the parent's embedded copy after a refetch nothing has propagated yet. Neither precedence is right in both directions. The occurrences of one response are equally fresh, so they need no recency rule. When a response enters the store, every occurrence is indexed by entity type and id (types come from the schema through the selection's field names, never from the id alone), and each occurrence resolves to the shallow union of all of them. Whatever writes an occurrence into a snapshot writes the union instead: useEntity, useEntityList and HasManyDataGrid for the rows they load, HasOneHandle and HasManyListHandle when they propagate embedded data. Related entities inside a union resolve to their own unions when they are written, so relations inside relations unite level by level without rewriting the response. Separate reads replace each other as they did before. Closes #123 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
…ity type Occurrences of one entity type at one selection node carry the same fields, so uniting them changes nothing. A walk plan, computed once per schema, selection and root type, lists the entity types the selection reaches at two or more nodes and the selection nodes on a path to them. A selection with no repeated type skips the walk; otherwise only those nodes are walked and only those types are collected. Also documents that EntityLoader writes responses without uniting occurrences, since it has a QuerySpec but neither the selection nor the schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
The walk plan climbed from each position of a repeated type to the root and stopped at the first selection object it had already marked. A reused fragment is one selection object at several positions, so the climb from its second position stopped at once and never marked that position's own ancestors: the branch was not walked, its occurrences were not united, and a narrower fragment occurrence could again replace fields a wider occurrence of the same entity carried. The climb now tracks tree positions. The plan is no longer cached per selection. A selection can still grow after its first fetch (mergeSelections extends nested selections in place, and a fragment's selection is shared), and computing the plan is one pass over the selection tree, independent of the response size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
…-merge # Conflicts: # packages/bindx-react/src/hooks/useEntityList.ts
Closes #123.
The bug
One query can reach the same entity through several paths, each with its own sub-selection. For example,
article.attachments(a => a.name().type())sits next toarticle.sections(s => s.article(r => r.attachments(a => a.name()))). Every occurrence is written into the entity's snapshot on its own path:useEntity/useEntityList;HasOneHandle/HasManyListHandlepropagate embedded data.When the narrower occurrence was written last, it replaced the fields the wider one carried, and those fields read back as
undefined. Nothing threw. The outcome depended only on render order.The fix: a response-local union
The occurrences of one server response are equally fresh, so uniting them needs no recency rule.
Index. When a response enters the store,
SnapshotStore.indexServerResponsegroups the entity occurrences in it by entity type and id (store/ResponseOccurrenceIndex.ts). Each occurrence of a repeated entity resolves to the frozen shallow union of all of them. An entity that occurs once is left as is, with no allocation.Walk plan. Occurrences of one type at one selection node carry the same fields, so uniting them changes nothing. For each response,
planOccurrenceWalklists:A selection with no repeated type skips the walk entirely. Otherwise only those paths are walked, and only those types are collected.
mergeSelectionsextends nested selections in place, and a fragment's selection is shared. The plan costs one pass over the selection tree (about 5 µs for a small cyclic selection) and does not depend on the response size.Entity types come from the schema through the selection. The walk starts from the root type and resolves each relation with
SchemaRegistry.getRelationTarget(parentType, fieldMeta.fieldName). Two entity types that share an id never meet. A relation whose target the schema cannot resolve is left out of the index; it is never matched by id alone.Write sites write
resolveServerOccurrence(occurrence)instead of the raw occurrence:useEntityuseEntityList(the whole list response is indexed together)HasManyDataGridfor the rows it loadsHasOneHandleandHasManyListHandlewhen they propagate embedded dataNested relations unite level by level. The union is shallow: a relation value inside it is one occurrence's raw value. The related entities in that value resolve to their own unions when they are written. So the response is never rewritten, and a cycle in it (an entity reached through itself) cannot produce a cyclic object.
Paginated has-many: array items and the
{ edges: [{ node }] }nodes of a connection are occurrences like any other, united by type and id. The list array itself, including its non-enumerabletotalCount, is untouched.Conflicting scalars: if two occurrences disagree on a scalar they both selected, which one server read should not produce, the occurrence visited last wins. The walk is breadth first, with relations in selection order and list items in list order. The outcome therefore follows from the response and the selection alone.
What this deliberately does not cover
main. A refetch, a read through another root, and the refresh after a persist are separate responses. They are never merged with what the store already holds.EntityLoader(core, not used by the React hooks) writes each response without uniting occurrences, so A narrower nested selection of the same entity overwrites the wider one's relation data (fields read back undefined) #123 remains for its users. Uniting needs the selection and the schema; the loader has only aQuerySpec. Its JSDoc now says so. Covering it means either passing a selection and a schema resolver into the loader, or teaching the index to walk aQuerySpec. Both are left for a follow-up.Tests
tests/react/relations/hasMany/nestedSameEntityNarrowerSelection.test.tsxruns every case in both read orders: narrower nested occurrence first, and wider direct occurrence first.useEntityListresponse;tests/unit/store/responseOccurrenceIndex.test.tscovers the index itself. The walk-plan cases are:It also covers:
Mutation checks:
main.Performance
20k rows, each with three entities (row, has-one, a has-one under it), run under
cpu-lease -n 2 --no-smt. Times are the median of 30 and cover indexing plus resolving every row.mainpath)JSON.parse(JSON.stringify(rows))Before the walk plan, the cyclic case walked every relation and took about 44 ms.
Verification
bun run typecheck: cleanbun run lint: 0 errors (existing warnings only)bun run test: 2137 pass, 0 failfix/hasmany-view-state) in a scratch worktree: the merge is clean, typecheck is clean, lint has 0 errors, and 2158 tests pass with 0 failures.Overlap with #122
#122 rewrites has-many store state. This PR does not touch that state. It adds one
resolveServerOccurrencecall inHasManyListHandle.ensureItemSnapshotsand two methods onSnapshotStore. The PRs merge cleanly.Note for merging with #132
#132 also changes the dependency array of the list-loading effect in
useEntityList.ts. Whichever of the two lands second should resolve the conflict as:History
The first two commits of this branch merged a narrower read into what the store already held, across reads. Review showed that no fixed precedence between the related snapshot and the embedded copy is right in both directions. The last commit removes that merge and replaces it with the response-local union described above.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5