refactor(bindx): decompose store internals (memoized reachability → RelationStore split)#49
Open
matej21 wants to merge 19 commits into
Conversation
computeReachableCreated() is an O(E+R) walk run on every dirty check and every post-persist sweep, previously recomputed from scratch each time. Each sub-store the walk reads (entity snapshots, meta, relations, roots) now exposes a monotonic getMutationVersion() bumped only when graph-relevant state changes — the entity key set / id index, existsOnServer / isPersisting, the root set, and relation edges. Pure value edits (setFieldValue/updateFields/...) and the per-render no-op getOrCreate* calls do not bump any counter, so the hot path stays warm. ReachabilityAnalyzer caches its result keyed on the sum of those counters. The sum is strictly increasing, so an unchanged sum proves nothing relevant changed and the cached set is returned without re-walking. All RelationStore map writes are routed through writeRelation/writeHasMany helpers so the bump cannot be missed; EntitySnapshotStore and EntityMetaStore bump selectively to avoid invalidating on value-only edits; RootRegistry bumps only on an actual change so the per-render registerParentChild -> unregister no-op does not thrash the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
White-box tests drive ReachabilityAnalyzer directly and spy on getLiveChildIds to assert the cache hits when nothing changes (incl. across pure field edits) and misses on each graph-affecting mutation type. A black-box test proves SnapshotStore propagates counter bumps end-to-end through getAllDirtyEntities (no stale create set). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
The temp→persisted rekey was a 9-store fan-out hand-written inline in
SnapshotStore.mapTempIdToPersistedId, and the same temp→persisted fact
was stored twice in two formats: SnapshotStore.rekeyedEntities
("Type:tempId" → "Type:persistedId") and EntityMetaStore.tempToPersistedId
("Type:key" → persistedId).
Introduce RekeyOrchestrator as the single owner of temp→persisted
identity. Both key resolution (resolveKey/resolveId) and persisted-id
queries (getPersistedId/isNewEntity) now derive from its one map, so the
two duplicate maps are gone. EntityMetaStore sheds tempToPersistedId and
its getPersistedId/isNewEntity/mapTempIdToPersistedId API; the
exists-on-server flip folds into its rekey().
Each participating sub-store implements a uniform Rekeyable.rekey(ctx)
interface, and the orchestrator drives them in one explicit, documented
order (previously load-bearing but implicit in the inline sequence).
SubscriptionManager keeps its own closure-redirect chain, which tracks
relation-key prefixes and stale unsubscribe closures rather than entity
identity. clear() now also clears the redirect map (previously leaked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
Pins resolveKey/resolveId/getPersistedId/isNewEntity across placeholder, temp, and persisted ids; asserts rekey visits every participant exactly once in order with a fully-derived context; and an end-to-end check that SnapshotStore resolves a created entity after persist via the orchestrator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
Introduces the read primitive that will let handles present the server baseline during a pessimistic persist WITHOUT the current mutate-store- to-server-then-restore dance. Inert for now: no consumer reads it yet (wiring + removal of mutate-restore lands in the next PR). - EntityMetaStore tracks a pessimisticInFlight set (a subset of the persisting set), set/cleared in the same setPersisting transition so the two cannot drift; it does not bump the reachability mutation counter since the flag drives presentation only. - SnapshotStore.getPresentationSnapshot returns the canonical snapshot except while pessimistically in-flight, when it returns a frozen server-baseline view (data === serverData) built without mutating the store. Non-pessimistic entities are returned verbatim, so optimistic and not-persisting share one path. - The SET_PERSISTING action carries an optional pessimistic flag; BatchPersister sets it when updateMode is pessimistic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
Asserts presentation equals canonical without the flag, equals the server baseline while pessimistically in-flight (canonical staying dirty, still reported as an update), tracks optimistic vs pessimistic, restores on clear, and that the baseline view is frozen and not aliased. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
Field and entity value display now read getPresentationSnapshot, so a pessimistically in-flight entity shows the server baseline. Dirty tracking keeps reading the canonical snapshot, so a field stays correctly dirty while its display shows the server value — the crucial split that lets the next commit delete the mutate-store-to-server-then-restore dance. - BaseHandle: getEntityData stays canonical (also used by has-many materialization); new getPresentationData for display. - FieldHandle.value -> presentation; isDirty compares canonical vs server. - EntityHandle.data -> presentation; getSnapshot/isDirty/serverData stay canonical. Scope note: relation display (has-one/has-many) still presents canonical state during a pessimistic in-flight window; presenting relations at the server baseline is a follow-up, cleaner once the has-one snapshot fallback is removed (keystone PR). Final post-persist states are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
With handle display reads now routed through getPresentationSnapshot, the store no longer needs to be reset to the server view during a pessimistic persist and restored afterward. Remove the whole capture/reset/restore machinery: - drop captureEntityStates, restoreEntityState and the CapturedEntityState type, and the per-update resetEntity + resetAllRelations block; - success now commits the (still-dirty) canonical state as the new server baseline via the same path optimistic mode always used; - failure leaves the entity dirty with no action — edits and created entities survive for a retry (P2) by construction, since the store was never mutated; - remove the now-dead store methods getAllRelationsForEntity, getAllHasManyForEntity and restoreHasManyState. The full pessimistic suite (commit-on-success, P2 preserve-edits and preserve-create on failure, isPersisting timing, batch) passes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
…ework Subscriber call-count harness covering has-one/has-many parent re-render on child change, the diamond (shared child notifies both parents), rekey preserving subscriptions, and the known append-only childToParents leak (disconnect still notifies the former parent) — the regression oracle for the upcoming reverse-index notification rework. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
Has-one relation state had two sources of truth: the RelationStore entry AND the related object embedded in the parent's snapshot data. HasOneHandle read from the store when an entry existed, else fell back to the embedded snapshot — invisible to reachability-based create detection, which reads membership exclusively from RelationStore. Mirror the has-many path (HasManyListHandle.materializeEmbeddedItems): add an idempotent ensureEntry() that materializes the relation entry from the parent's embedded data on every relation-state read (relatedId/state/isDirty), then drop the snapshot fallback so RelationStore is the single source of truth. - Initial materialization uses the non-notifying getOrCreateRelation, deriving the server baseline from the parent's serverData so a freshly loaded relation is not dirty (currentId === serverId, state === serverState). - A parent re-fetch (embedded reference changed) advances the server baseline only when the relation is not locally dirty, via hasEmbeddedDataChanged — child-snapshot propagation keeps sole ownership of the propagation slot in ensureRelatedEntitySnapshot, so the two paths never double-consume it. - Local connect/disconnect, placeholders, and creating entries are never clobbered (detected as locally-dirty / id-mismatch). A created child connected via a has-one now appears in reachability and getAllDirtyEntities() as a create with no explicit setRelation, with no analyzer change. Tests cover loaded-not-dirty materialization, the dropped fallback, and the created-child-as-create case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
…ve childToParents Parent re-render propagation used a separate append-only childToParents registry in SubscriptionManager, populated on every render via registerParentChild and never cleaned on disconnect (a leak). RelationStore is now the single source of truth for relation membership, so derive 'which parents reference this child' from its live edges instead. - Add RelationStore.getParentKeysForChild(childId): an on-demand scan of the live has-one/has-many edges, with liveness matching getLiveChildIds exactly. Chosen over an incremental reverse index: correct by construction (reads the same maps as the forward query), so nothing can drift on disconnect/rekey/clear. - Inject it into SubscriptionManager via a small ParentKeyLookup interface, mirroring SnapshotVersionBumper. notifyEntitySubscribers derives parents from it, preserving the recursion + cycle guard and the parent snapshot bump. - Remove childToParents, registerParentChild/unregisterParentChild bodies, and their migration in rekey(). SnapshotStore.registerParentChild keeps only the load-bearing roots.unregister(childKey) side effect; unregisterParentChild is deleted. Render-time callers (handles, ActionDispatcher) are unchanged and now trigger only the root-unregister half — the relation edge they already set up is the notification source. A disconnected child no longer notifies its former parent (the leak is gone). Tests: flip the harness disconnect scenario to the no-leak behavior; update the notification harness diamond and the rekey/snapshotStore/actionDispatcher tests to establish real relation edges (the new notification source) instead of bare registerParentChild calls. Add getParentKeysForChild.test.ts with a randomized cross-check proving the reverse query always agrees with getLiveChildIds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
Replace StoredHasManyState's plannedConnections: Set and createdEntities: Set with a single plannedAdditions: Map<string, 'created' | 'connected'>. The map keys are exactly the old plannedConnections; keys whose value is 'created' are exactly the old createdEntities, making the "createdEntities ⊆ plannedConnections" invariant structural and removing one mutable field (5→4). add() records 'created', connect()/embedded-connect record 'connected' without downgrading an existing 'created' entry. removeFromHasMany branches on get(id) === 'created'. getLiveChildIds, getParentKeysForChild, dirty tracking, commit, rekey, export/import and computeDefaultOrderedIds updated to the new field. SnapshotStore accessors keep their Set/boolean return types so external callers are unaffected; MutationCollector emits create vs connect from the kind. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
…HasManyStore Split the ~900-line RelationStore into two focused collaborator classes behind an unchanged public API: - HasOneStore owns has-one relation state and its own mutation-version counter. - HasManyStore owns has-many list state, computeDefaultOrderedIds, and its own mutation-version counter. - relationKey.ts holds the shared parentKeyFromRelationKey helper. - RelationStore is now a thin facade composing both: it sums the two mutation versions, unions getLiveChildIds/getParentKeysForChild/getDirtyRelations, and fans rekey/replaceEntityId/removeOwnedRelations/commit/reset/clear out to both sub-stores. Public API and all import paths (StoredRelationState, StoredHasManyState, HasManyRemovalType, HasManyAdditionKind, computeDefaultOrderedIds re-exported from RelationStore.js) are preserved; consumers see no change. Behavior is identical — pure structural move. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
ensureEntry()/advanceServerBaselineOnRefetch runs on every has-one read, including during React render. Its refetch branch wrote the new server baseline via the notifying setRelation, calling subscribers mid-render and violating the external-store contract. Add a skipNotify path to SnapshotStore.setRelation and use it here — the parent re-fetch that produced the new embedded reference already notified subscribers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
connectExistingToHasMany appended to orderedIds unconditionally, so re-materializing the same embedded connect reference surfaced the list item twice. Mirror planHasManyConnection: only touch an explicit order and guard with !includes. Also pin the load-bearing no-downgrade invariant (a connect after a create on the same id must keep emitting a create) on both the planHasManyConnection and connectExistingToHasMany paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
The memoization suite covered only has-many edge changes. Add has-one setRelation connect/disconnect cases and a direct getMutationVersion test proving the facade counter sums both sub-stores — so dropping the has-one term (a stale-cache create-detection bug) would be caught. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
The reverse parent lookup matches on a bare child id, deliberately relying on the store-wide global-id-uniqueness invariant (the same one behind EntitySnapshotStore idIndex/keyForId and the forward reachability walk). Document this on getParentKeysForChild and getParentKeys, and pin it with a test so a future type-aware change trips deliberately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
getParentKeysForChild and getLiveChildIds scanned every relation entry, so parent-notification and the reachability walk were O(total relations) per call — a per-edit cost that scaled with store size (benchmarked ~550 µs/edit at 4000 rows vs ~3 µs before the childToParents map was removed). Introduce RelationEdgeIndex: a bidirectional, reference-counted index of live parent<->child edges that each sub-store maintains through its single write/delete chokepoint by diffing the previous against the next live set. Both queries become O(degree) and the two directions stay consistent by construction (the failure mode of the old append-only childToParents map). Liveness is now a single predicate per sub-store, consumed by the chokepoint, instead of being duplicated across the forward and reverse scans. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
Pin the reference-counting and migration cases the bidirectional index introduces — same parent reaching a child through several fields, id replacement, owner rekey, bulk removal, commit/reset — alongside the existing randomized forward/reverse cross-check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
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.
Stacked on #48 (
fix/hasmany-remove-created-entity-leaks-orphan-create). Base is that branch, notmain— review/merge #48 first.Decomposes the bindx store internals in 13 reviewable, test-paired steps. Each
refactor/perf/featcommit is preceded or followed by atestcommit that pins the behavior it touches.What changes
computeReachableCreated()(run on every dirty check, ≈ keystroke) is cached behind a sum of monotonic sub-store mutation counters; pure field edits keep the cache warm.getPresentationSnapshot, removing the need to mutate the store to the server view during a pessimistic persist.RelationStoremade authoritative for has-one; parent notification derived from relation edges (childToParentsremoved); has-many planned-additions collapsed into one map.HasOneStore+HasManyStore.Rebase note
Rebased onto the current tip of the base branch (
dfc1966, "address review findings"). One semantic conflict was resolved by hand inBatchPersister.ts: dfc1966's pessimistic partial-failure fix touched the same capture/restore machinery this stack deletes. Resolution — the dance is removed (per this stack's intent), while dfc1966'slet result+ success-gatedsweepUnreachableCreated()are preserved (the conservative PERSIST-1 fix). The reachability memoization test was adapted to dfc1966's new no-created-snapshot early-out fast path (invalidation proven by the result, matching the sibling root-(un)register test).Tests
Full suite green except:
:15180(environmental, not run in this checkout);FormHasManyRelationScope > tracks dirty state from has-many relationfailure that also fails on the pre-rebase tip.Neither is introduced by this stack. Reachability + persistence (the rebase-affected areas) are fully green.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj