Skip to content

fix(bindx): key has-many state by the schema field name, views inside it - #122

Merged
matej21 merged 14 commits into
mainfrom
fix/hasmany-view-state
Sep 28, 2026
Merged

matej21 merged 14 commits into
mainfrom
fix/hasmany-view-state

Conversation

@matej21

@matej21 matej21 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

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:

Symptom Effect
add() persisted as a detached root create instead of a nested one; with a non-null inverse relation the mutation is rejected outright
remove() completely silent no-op — no mutation, no error, entity stays dirty
persistScope({ type: 'relation', relationName }) saved nothing: the dirty-relation list reported the alias, BatchPersister matches it against the field name
usePersistEntity().dirtyRelations exposed tags_a7x9k2 to application code instead of tags

The fix

The state is keyed by the schema field name alone, with per-args views inside it:

interface StoredHasManyState {
	views: Map<string, HasManyView>            // alias -> { serverIds, orderedIds, membership }
	plannedRemovals: Map<string, HasManyRemovalType>
	plannedAdditions: Map<string, PlannedHasManyAddition>   // { kind, origins }
	version: number
}

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:

  • A removal says the item is no longer a member of the relation. Every view is a subset of it, so the item is hidden everywhere — otherwise a sibling view would keep offering a row the persist is about to disconnect.
  • An addition says the item is a member, which does not settle whether it belongs in a filtered view; the client cannot evaluate the filter. So it renders in the views it was made in, plus any view whose args cannot exclude anything (membership: 'total') — an unfiltered list has no filter to violate.

MutationCollector, BatchPersister and errorPathResolver are 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.ts with 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). membership was 'total' only when the alias equaled the field name — but an alias is minted for orderBy alone, 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: EntityHandle declares the membership from hasManyParams before 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 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 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 the add() created (c23a8ba). An add() 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 by entityType: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, HasManyRelationProjection and HasManyViewProjection are exported next to StoredHasManyState, 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

  • 2142 tests pass, 0 fail (2107 before this branch). bun run typecheck clean, bun run lint 0 errors (16 warnings, all in files this PR does not touch).
  • The HasMany add() persists a detached root create when the relation is selected with args (orderBy) #121 regression tests were run against the pre-fix tree in a separate worktree: all four with orderBy cases fail there, all four without args control cases pass — including the two symptoms that were otherwise only inferred from reading the code.
  • Each follow-up fix was likewise confirmed to fail before it: the two reconciliation tests against 11ff4fe, and the ordering-only membership test with the declaration removed.
  • Second round, each new test confirmed to fail with its fix reverted: the four sibling-manual-order tests with the append limited to the origin view, the re-connected-server-row test without the server-rows condition, the undo-of-add() test with the old restore of unknown views, the entity-scoped declaration test with the entity type dropped from the key. Both orderByViewMembership tests fail against origin/main.
  • Undo over an args-view 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 — verified by temporarily reverting to the naive version).

Breaking changes

  • StoredHasManyState changes shape. It is exported from index.ts; no other package reads its fields.
  • HasManyViewProjection loses exists, plannedRemovals and plannedAdditions — a view is asked for its server rows and its manual order; the pending writes belong to the relation.
  • SnapshotStore.commitHasMany is gone. The post-persist path is reconcileSentHasMany; commitAllRelations covers the bulk case.
  • HasManyListHandle.isDirty and reset() 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.
  • The alias parameter is gone from removeFromHasMany, planHasManyRemoval, resetHasMany and connectExistingToHasMany, where it never meant anything.
  • RelationStore.getHasMany is now getHasManyState.
  • The alias parameter is also gone from getHasManyPlannedRemovals, getHasManyPlannedConnections, isHasManyItemCreated, getHasManyCreatedEntities and reconcileSentHasMany.
  • removeFromList() loses its 6th parameter (alias), and RemoveFromListAction loses its alias field.
  • SnapshotStore.getHasMany drops alias and returns a HasManyRelationProjection (server baseline unioned across views, no orderedIds) instead of the stored state.
  • SnapshotStore.getOrCreateHasMany returns void instead of the state.
  • getLiveHasManyServerIds returns the server ids per view (ReadonlyMap<alias, ReadonlySet<id>>) instead of one set.
  • The computeDefaultOrderedIds re-export from RelationStore.ts is removed; the per-view default order lives in hasManyState.ts as computeViewOrderedIds.

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.

commitAllRelations has no production caller — it was already dead before this branch, and this PR only migrated the tests of the deleted commitHasMany onto it. Production commits through reconcileSentHasMany. Removing it (and moving those tests) belongs in its own PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5

matej21 and others added 8 commits September 22, 2026 15:31
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
matej21 and others added 6 commits September 28, 2026 16:50
…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
@matej21
matej21 merged commit 4569329 into main Sep 28, 2026
4 checks passed
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.

HasMany add() persists a detached root create when the relation is selected with args (orderBy)

1 participant