Skip to content

Reachability-based create detection (replaces eager orphan-purge)#48

Open
matej21 wants to merge 3 commits into
mainfrom
fix/hasmany-remove-created-entity-leaks-orphan-create
Open

Reachability-based create detection (replaces eager orphan-purge)#48
matej21 wants to merge 3 commits into
mainfrom
fix/hasmany-remove-created-entity-leaks-orphan-create

Conversation

@matej21

@matej21 matej21 commented Jun 22, 2026

Copy link
Copy Markdown
Member

What & why

Resolves the orphan-create leak class — issue #47 and the bug-hunt that followed it — structurally instead of patching each detach path.

Root cause: getAllDirtyEntities() decided create-ness from a single signal (snapshot exists with existsOnServer=false), while attachment lived in a separate RelationStore. Two sources of truth kept in sync by hand → every detach path had to manually purge the snapshot or it leaked a phantom create (a global persist then tried to create a row the user removed). The prior fix grew into whack-a-mole eager-purge (cascade, refcounts, per-path purges).

The invariant

A created entity is a create iff it is reachable from a root through live relations.

  • Roots = server entities ∪ in-flight (persisting) entities ∪ explicitly-created top-level entities.
  • createEntity auto-roots; registerParentChild un-roots (a child is anchored by its parent). A top-level <Entity create> / useEntityList add stays a root.
  • The graph is read from RelationStore (live membership), never the subscription registry.

A detached created entity is now simply unreachable → produces no mutation. The whole eager-purge layer is deleted (net -25 LOC of production despite two new modules); ActionDispatcher returns to plain relation updates.

Also in this PR

  • Lazy sweep + unmount cleanup reclaim detached-create snapshots (memory hygiene; correctness never depends on it). Diamonds (a created child in two lists) stay live while any parent references them.
  • Pessimistic persist failure → preserve-for-retry (P2): instead of stranding the user at the server view, the captured state is restored without committing, so edits and creates survive dirty for a retry.
  • Dead relation API removed (cancelHasManyConnection/Removal); removeFromHasMany returns boolean.

Testing

  • 1587 pass / 1 fail (excluding browser tests, which require the live playground on :15180).
  • The 1 failure — formRelations › tracks dirty state from has-many relation — is pre-existing and unrelated: it fails identically on clean main (a separate has-many-disconnect dirty-tracking bug, out of scope here).
  • Typecheck clean (apart from a pre-existing packages/example/App.tsx duplicate-graphql-client install error on main).
  • New coverage: lingering-orphan, diamond, cascade-drop, sweep, pessimistic-window, and P2 (update + create) cases.

Follow-ups (not in this PR)

Investigation surfaced a unifying theme — RelationStore should be the single authoritative source for relation state. Candidates: unify the parent-child graph (delete childToParents; needs eager has-one materialization first), simplify the 5-field has-many state model, and redesign the pessimistic mutate-restore as a presentation flag. Tracked for a later decision.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj

matej21 and others added 3 commits June 22, 2026 17:46
…an-purge)

Resolve the orphan-`create` leak class (#47 + bughunt) structurally instead of
patching each detach path. A created entity is a `create` iff it is reachable
from a root through live relations; a detached created entity is simply
unreachable and produces no mutation. This replaces the eager-purge machinery
(cascade, purgeOrphanedCreated, isEntityReferenced refcounts, per-detach-path
purges) with a single invariant.

- RootRegistry + ReachabilityAnalyzer + RelationStore.getLiveChildIds; id->key
  index in EntitySnapshotStore for O(1) child resolution
- createEntity auto-roots; registerParentChild un-roots (a child is anchored by
  its parent); top-level <Entity create> / useEntityList adds stay roots
- DirtyTracker create branch gated on reachability; ActionDispatcher back to
  plain relation updates (isNeverPersisted kept only for DELETE_RELATION
  relation-state semantics: never-persisted has-one target reverts to
  disconnected, not deleted)
- lazy sweep (sweepUnreachableCreated, post-persist) + React unmount cleanup
  reclaim detached-create snapshots; correctness never depends on either
- pessimistic persist failure preserves the user's edits/creates for retry (P2)
  instead of stranding them at the server view
- drop dead relation API (cancelHasManyConnection/Removal); removeFromHasMany
  returns boolean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
- reachabilityCreateDetection: lingering-orphan, diamond, cascade-drop, sweep and
  pessimistic-window cases proving the gate independent of any eager purge
- rewrite orphan / hasMany-remove tests to assert no-create behavior instead of
  the eager-purge mechanism (a detached snapshot may linger until the lazy sweep)
- pessimistic tests assert P2 preserve-for-retry on failure (update + create)
- update removeFromHasMany / REMOVE_FROM_LIST call sites for dropped
  itemType / targetType params

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeRcE9offiYmcy3c7fiuj
…a loss, unmount cleanup, perf

- PERSIST-1: on a failed pessimistic transaction restore ALL captured states (not
  only the ones whose own mutation failed) and gate the post-persist sweep to full
  success, so a succeeded-but-reset parent and its inline-created child survive a
  partial batch failure (the default non-atomic path) instead of being stranded/swept
- PERSIST-2: restore relation placeholderData on commit=false so an inline has-one
  create and its dirty signal survive a pessimistic retry
- REACT-1: make the create-mode / list unmount cleanup reachability-aware
  (unregisterRootEntity + sweepUnreachableCreated) so a draft connected into another
  live parent (diamond) is preserved instead of hard-removed
- REACT-2: re-seed the create-mode draft under the same temp id so the form survives
  a React StrictMode mount cycle
- PERF-1: early-out computeReachableCreated when no never-persisted snapshot exists,
  keeping the common update-only dirty check off the O(V*(R+H)) graph walk
- cleanup: drop dead RootRegistry.has() and EntitySnapshotStore.findByEntityId
  (isNeverPersisted resolves via keyForId), extract ActionDispatcher disconnectRelation
  helper, guard idIndex removal against id collisions, document removeEntity's
  no-inbound-cleanup contract
- tests: partial-failure pessimistic (incl. inline-child survival), unmount cleanup
  (discard / persisted-kept / diamond / StrictMode), getLiveChildIds deleted-edge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4RHL9A7AGr5gMq6d4Qhk7
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.

1 participant