fix(bindx): key has-many state by the schema field name, views inside it - #122
Merged
Merged
Conversation
HasManyStore mixed the keyed map, the write chokepoint and the three indexes with the pure algebra over a single state. Splitting them gets the file back under the size limit before the view restructure grows it again. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
A has-many selected with args (filter/orderBy/limit/offset) is fetched under a generated alias, and the store used that alias as the relation key. Everything that names the relation the way the schema and the server do then failed to find the pending writes: - add() fell through to a detached root create (issue #121) — against a Contember project with a non-null inverse the mutation is rejected outright; - remove() was a completely silent no-op — no mutation, no error, still dirty; - persistScope({ type: 'relation', relationName }) matched nothing, because the dirty-relation list reported the alias instead of the field name; - usePersistEntity().dirtyRelations exposed `tags_a7x9k2` to application code. The state is now keyed by the schema field name alone, with per-args views inside it. Reads stay isolated per view; the pending writes belong to the relation, because the backend has one relation and one mutation input for it. Subset closure only holds one way, so the two directions are asymmetric: a removal hides the item in every view, while an addition renders in the views it was made in plus any view whose args cannot exclude it. MutationCollector, BatchPersister and errorPathResolver are unchanged — they already addressed the relation correctly. BC: StoredHasManyState changes shape; HasManyListHandle.isDirty and reset() are now relation-scoped, since persisting either view sends the whole relation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
Undo over a relation read under a generated alias had no coverage at all. The move case pins the per-view splice in applyRelationImage: restoring the live views wholesale instead of the recorded ordering makes undo of a move silently do nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
…aseline The baseline of a relation is the UNION across its views, so a row that joins no view is a server row the store has lost. Two paths could produce one once the state was keyed by the field name with views inside it: - a confirmed addition whose local record was cancelled while the request was in flight folded only into views that cannot exclude it, and a relation read only through filtered views has none. The compensating removal masked it until the user reset the relation, at which point the row vanished from the client while it existed on the server; - the disconnect rebase re-planned the connection with origins spanning EVERY view, which is the "guess membership in a filtered view" the module's own asymmetry rule forbids. Both now go through renderTargets(): render where the row belongs, else in the views that cannot exclude it, else park it in the unparameterized view — which is total by definition, so parking shows it to nobody it does not belong to. The rebase reads which views listed the row as the baseline drops it, instead of assuming all of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
…lias A view was 'total' — its args cannot exclude a member — only when its alias equaled the field name. But an alias is minted for `orderBy` alone, and ordering leaves nobody out, so two sorted views of one relation never saw each other's additions: the row added in one silently missed the other until a refetch. The module's own doc already defined 'total' as "no filter/limit/offset"; the alias string cannot answer that, because it is a hash of the params. The selection can. EntityHandle declares the membership from `hasManyParams` before it hands out a handle — strictly before any path can materialize the view — and the store keeps it in a small "fieldName:alias" registry, one entry per distinct selection. An undeclared alias stays 'partial': the safe direction, since the client then never claims membership it cannot prove. Declaring beats threading a parameter: it also covers views materialized lazily by a mutator, and explicit `as` aliases, which carry no hash at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
- HasManyViewProjection carried `exists`, `plannedRemovals` and `plannedAdditions`, none of which has a reader: a view is asked for its server rows and its manual order, and the pending writes belong to the relation. Building them meant a Map copy plus a pass over every addition on each call. - SnapshotStore.connectExistingToHasMany took an `alias` its only caller never passed — an untested extension point for a path that always addresses the field. - RelationStore.getHasMany returned the raw state while SnapshotStore.getHasMany returns a projection with no views at all. Renaming the raw one getHasManyState keeps an edit inside SnapshotStore from reaching for the wrong layer and silently losing the per-view data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
… projections isDirty ran per render for every mounted list and materialized two projections to read three sizes and one null check — a relation projection unions every view's server ids and copies two maps, a view projection copied a set. It now asks two predicates that touch the stored state directly. getLiveHasManyServerIds deep-cloned the whole relation, orderedIds and every addition's origins included, to hand back per-view server ids the caller only probes with has(). UndoManager.rekeyStacks calls it for every has-many cell of the undo stack, the redo stack and the pending entry on each temp -> persisted mapping. It now reads the sets by reference: a stored set is never mutated in place, only replaced, so a reference can go stale but cannot be written through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
The aliases were hand-written as `items_ordered` / `items_filtered` while the assertions reason about two filtered views — one comment even called the "ordered" one filtered. Since a view that only orders is no longer treated as filtered, the names now say the opposite of what the test pins. Both fixtures come from generateHasManyAlias with a real filter instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgKwamEYmRS43qVx5t4BSm
…cted it The membership declared for an args-view was keyed by "fieldName:alias". Two entities with a has-many of the same name can select different args under the same explicit alias, so a declaration made for one of them classified the other's view too — and an ordering-only declaration would then show additions in a view whose args filter them out. The key now starts with the entity type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
A manual order replaces a view's default order, and the default order is the only place a pending addition otherwise appears. An addition reached the manual order of the view it was made in and nowhere else, so a sibling view that must show it — one whose args cannot exclude anything — hid it until the next refetch whenever that sibling carried a manual order. That covers a move in a sorted view followed by add() in another, two sorted views that each add(), a connect in a filtered view next to an unfiltered view that already add()ed, and a sibling order that survives the persist of the addition. Recording an addition now appends the item to the manual order of every view it renders in, the origin included, which also subsumes the per-origin append the connect paths did on their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
An add() through a view nothing had read yet creates that view with a manual order. The undo image does not know the view, and restore kept the live order of every view it did not know, so undoing the add() left the created row listed there — a row for an entity the undo had just removed from the relation. A view missing from the image did not exist before the gesture, so it had no arranged order to restore; it now falls back to the default order, which derives from the restored writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
StoredHasManyState is exported for the undo/export paths, but its members were not: HasManyView, PlannedHasManyAddition and the two unions they carry, nor the projections SnapshotStore.getHasMany and getHasManyView return. A consumer could hold the state but not name what is inside it. Also documents a known limitation of the relation baseline: it is the union across views and views are never dropped, so a view nobody refetches any more can keep a row the server has since removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
The filtered-view case passed before the has-many state was shared: a connection made through another view never reached the filtered one at all, so "it does not show it" held for the wrong reason. The view now also reports the relation as dirty, which only holds when the write reached it — so the test fails on a store that keeps views apart and pins that the exclusion comes from the membership rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
…ed it A removal drops the item from every manual order. Connecting it again appended it only to the manual orders of views the addition renders in, and a filtered view is not one of them unless the connect came from it — so a filtered view with a manual order lost a row its own server data returned, until a refetch. A view whose server rows hold the item now gets it back too. Also covers the rest of the manual-order behaviour across views: an addition stays out of views whose filter, limit or offset may exclude it, an item added through several views is listed once in each, a new row removed and connected again stays out of a filtered view, and undo/redo of add, connect, remove and move restore every view's manual order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
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.
Closes #121.
The bug
A has-many selected with args (
filter/orderBy/limit/offset) is fetched under a generated alias (tags_a7x9k2), and the store used that alias as the relation key. Everything that names the relation the way the schema and the server do then failed to find the pending writes.Four symptoms, one cause — the first is the reported one, the rest turned up while tracing it:
add()createinstead of a nested one; with a non-null inverse relation the mutation is rejected outrightremove()persistScope({ type: 'relation', relationName })BatchPersistermatches it against the field nameusePersistEntity().dirtyRelationstags_a7x9k2to application code instead oftagsThe fix
The state is keyed by the schema field name alone, with per-args views inside it:
Reads stay isolated per view — that is what the alias is for. The pending writes are not, because the backend has one relation and one mutation input for it.
The two directions are asymmetric on purpose, because subset closure only holds one way:
membership: 'total') — an unfiltered list has no filter to violate.MutationCollector,BatchPersisteranderrorPathResolverare unchanged. They addressed the relation correctly all along; the store was the one disagreeing.The first commit is a pure extraction of the state algebra into
hasManyState.tswith no behaviour change, so the shape change in the second one is reviewable on its own.Review follow-ups
Commits 4-8 answer a review of the three above.
A server-confirmed member could leave the baseline (
6f49cf6). The relation's baseline is the union across its views, so a row that joins no view is a server row the store has lost. A confirmed addition whose local record was cancelled mid-flight folded only into views that cannot exclude it — and a relation read only through filtered views has none. The compensating removal masked it until the user reset the relation, at which point the row vanished from the client while it existed on the server. The disconnect rebase had the mirror problem: it re-planned the connection with origins spanning every view, the "guess membership in a filtered view" the asymmetry rule forbids. Both now go through one rule: render where the row belongs, else in the views that cannot exclude it, else park it in the unparameterized view (total by definition, so it is shown to nobody it does not belong to).A view is now classified by its params, not by its alias (
8679f9a).membershipwas'total'only when the alias equaled the field name — but an alias is minted fororderByalone, and ordering leaves nobody out. Two sorted views of one relation therefore never saw each other's additions. An alias is a hash of the params, so only the selection can answer this:EntityHandledeclares the membership fromhasManyParamsbefore it hands out a handle, strictly before any path can materialize the view. An undeclared alias stays'partial'— the safe direction.Plus: dead surface removed, the raw accessor renamed apart from the projections (
6497f7c), the per-render dirty check and the undo rekey lookup no longer materialize projections they do not read (94623e9), and the args-view undo fixtures renamed after what they actually are (e623541).Second review round
Commits 9-14.
An addition now reaches every manual order it renders in (
981dca5). A manual order replaces a view's default order, and the default order is the only place a pending addition otherwise appears. The addition reached the manual order of its origin view only, so a sibling view that must show it (membership: 'total') hid it until a refetch whenever that sibling had a manual order: a move in a sorted view followed byadd()in another, two sorted views that eachadd(), a connect in a filtered view next to an unfiltered view that alreadyadd()ed, and a sibling order that survives the persist of the addition. Recording an addition now appends it to the manual order of every view it renders in. A filtered sibling still does not gain it. A view whose own server rows hold the item gets it back as well (9e3a690): a removal drops the item from every manual order, so without that, removing a server row and connecting it again from another view lost it from a filtered view that had listed it. The same commit covers the rest of the manual-order behaviour: limit/offset views, no duplicates across repeated connects, and undo/redo of add, connect, remove and move across views with manual orders.Undoing an
add()no longer leaves a ghost row in the view theadd()created (c23a8ba). Anadd()through a view nothing had read yet creates that view with a manual order the undo image does not know, and restore kept the live order of unknown views. A view missing from the image did not exist before the gesture, so it now falls back to the default order of the restored writes.A membership declaration is scoped to the entity (
82ed41b): keyed byentityType:fieldName:alias, so two entities with a has-many of the same name and the same explicit alias do not classify each other's views.Exports (
7362bb6):HasManyView,HasManyViewMembership,PlannedHasManyAddition,HasManyAdditionKind,HasManyRelationProjectionandHasManyViewProjectionare exported next toStoredHasManyState, so a consumer that holds the state can name its members and the projections the store returns.The filtered-view membership test now fails on
main(fab1435). It passed there for the wrong reason: a write made through another view never reached the filtered view at all. It now also asserts that the filtered view reports the relation as dirty, which holds only when the write reached it.Verification
bun run typecheckclean,bun run lint0 errors (16 warnings, all in files this PR does not touch).with orderBycases fail there, all fourwithout argscontrol cases pass — including the two symptoms that were otherwise only inferred from reading the code.11ff4fe, and the ordering-only membership test with the declaration removed.add()test with the old restore of unknown views, the entity-scoped declaration test with the entity type dropped from the key. BothorderByViewMembershiptests fail againstorigin/main.movecase pins the per-view splice inapplyRelationImage(restoring the live views wholesale instead of the recorded ordering makes undo of a move silently do nothing — verified by temporarily reverting to the naive version).Breaking changes
StoredHasManyStatechanges shape. It is exported fromindex.ts; no other package reads its fields.HasManyViewProjectionlosesexists,plannedRemovalsandplannedAdditions— a view is asked for its server rows and its manual order; the pending writes belong to the relation.SnapshotStore.commitHasManyis gone. The post-persist path isreconcileSentHasMany;commitAllRelationscovers the bulk case.HasManyListHandle.isDirtyandreset()are now relation-scoped. Persisting either view sends the whole relation, so a view reporting "clean" while pushing its sibling's writes would be incoherent.aliasparameter is gone fromremoveFromHasMany,planHasManyRemoval,resetHasManyandconnectExistingToHasMany, where it never meant anything.RelationStore.getHasManyis nowgetHasManyState.aliasparameter is also gone fromgetHasManyPlannedRemovals,getHasManyPlannedConnections,isHasManyItemCreated,getHasManyCreatedEntitiesandreconcileSentHasMany.removeFromList()loses its 6th parameter (alias), andRemoveFromListActionloses itsaliasfield.SnapshotStore.getHasManydropsaliasand returns aHasManyRelationProjection(server baseline unioned across views, noorderedIds) instead of the stored state.SnapshotStore.getOrCreateHasManyreturnsvoidinstead of the state.getLiveHasManyServerIdsreturns the server ids per view (ReadonlyMap<alias, ReadonlySet<id>>) instead of one set.computeDefaultOrderedIdsre-export fromRelationStore.tsis removed; the per-view default order lives inhasManyState.tsascomputeViewOrderedIds.Known, not addressed here
The relation's server baseline is the union across its views, and views are never dropped. A filtered view that nobody refetches any more can therefore keep a row in the baseline after the server removed it from the relation and the unfiltered view was refetched without it. Fixing that needs a view lifecycle (dropping a view when its last handle unmounts, or a per-view fetch generation), which is a redesign beyond this PR; it is documented at
relationServerIds.commitAllRelationshas no production caller — it was already dead before this branch, and this PR only migrated the tests of the deletedcommitHasManyonto it. Production commits throughreconcileSentHasMany. Removing it (and moving those tests) belongs in its own PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5