refactor(bindx): rebuild undo/redo as a write-journal over the decomposed store#50
Open
matej21 wants to merge 28 commits into
Open
refactor(bindx): rebuild undo/redo as a write-journal over the decomposed store#50matej21 wants to merge 28 commits into
matej21 wants to merge 28 commits into
Conversation
…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
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
6cb7d77 to
52bcf43
Compare
…osed store Replaces the static-projection snapshot-restore undo (getAffectedKeys computed before execution) with a write-journal keyed by what each gesture actually writes. Fixes three root-cause defects: created entities in a list weren't captured (lost on undo->sweep->redo), root registration wasn't captured (phantom/lost creates), and undo didn't survive a temp->persisted rekey (stale-key corruption). Core: - UndoJournal records each gesture (one dispatch / one handle transaction) as a JournalEntry of editable-layer pre-images, first-writer-wins per cell. - SnapshotStore implements JournalTarget (exportEntityCell/exportRelationCell/ exportHasManyCell + applyJournalImages) and gains beginTransaction/ commitTransaction/transaction(); mutating methods record before writing. - ActionDispatcher.dispatch and the pre-create handle gestures (HasManyListHandle.add, HasOneHandle.create) open transactions so one gesture = one undo unit. - Restore splices only the editable layer onto the LIVE server baseline, so undoing a persisted edit re-dirties against the current baseline. - Persist survival: the journal rekeys stacked entries (keys + embedded ids), seals now-persisted creates, and rebases has-many membership so a persisted child stays in the list under both default and explicit ordering. - UndoManager becomes a thin policy layer (debounce/manual grouping, block during persist, rekey-of-stacks); the createMiddleware() API is preserved. - Removes the dead actionClassification static projection. Tests: 36 green. Original undo.test.ts unchanged, plus undo-stabilization.test.ts (3 characterization bugs + scale + handle gesture) and undo-journal.test.ts (seal incl. the create-across-save falsification, rekey embedded-id, has-many move/disconnect/delete, multi-cell atomic, redo-after-persist, absent-relation restore, edge cases). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbKRVbhqE2N3uTi9mbaWb6
52bcf43 to
3abef4b
Compare
A gesture that detaches a created (never-persisted) child writes only the parent's relation/has-many cell, so the journal entry carried just that cell. sweepUnreachableCreated() — run post-persist and via the unmount cleanup, outside any journal transaction — then reclaimed the child's snapshot and owned relation state, and a later undo restored membership pointing at an entity that no longer existed: dangling reference, lost unsaved data. Entry-closure invariant: on commit each entry is now folded over the created, currently-unreachable subgraph its relation/has-many pre-images reference (entity image + owned relation cells, transitively through nested creates), gated by the exact sweep predicate. Undo entries are self-contained no matter what reclaims memory in between; reachable created siblings stay out, keeping entries O(edit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsR5r5KJFrV63yFCZtSQN4
Nothing wired SnapshotStore.clear() to the undo system, so after a full store wipe (logout / teardown / schema switch) the stacks kept pre-images of the wiped world — undo would resurrect stale entities into an empty store and canUndo misreported. Latent today (only tests call clear()), but a real hole the moment a teardown path uses it. Mirrors the existing rekey forwarding: store → journal.clear() → onClear → UndoManager.clear(). A mid-gesture clear drops the open transaction's recorded cells while keeping begin/commit depth paired. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsR5r5KJFrV63yFCZtSQN4
…ters The "record before writing" convention behind the write-journal had no enforcement: a future mutating SnapshotStore method that forgets its journal.record* call silently corrupts undo. Sub-stores now bump a cheap editable-write counter at their write funnels (a different layer than the record calls, so a forgotten record still trips it); at each outermost transaction close the journal compares per-kind deltas against the kinds of recorded cells and throws UnrecordedWriteError naming the missing kind. Server ingestion, baseline commits, undo restore imports, rekey, sweep and clear() are classified as legitimately unjournaled and do not count. Always-on: integer bumps plus an O(1) comparison per gesture, verified false-positive-free across the full suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsR5r5KJFrV63yFCZtSQN4
f019160 to
02d7a79
Compare
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.
Why
The existing undo/redo worked but stood on a fragile mechanism that diverged from the post-decomposition store. Capture was driven by a static projection of the action computed before execution (
getAffectedKeys), with three confirmed root-cause defects:RootRegistrywas never captured. A top-level created entity's "pending create" status is anchored solely by its root membership → phantom/lost creates after undo.What
Rebuilds undo as a write-journal keyed by what each gesture actually writes. The key invariant exploited: derived state (edge index, reachability cache, idIndex) is a pure function of primary state, rebuilt through the write chokepoints — so undo only restores primary state and the rest follows. No event/interceptor replay.
UndoJournalrecords each gesture (one dispatch / one handle transaction) as aJournalEntryof editable-layer pre-images, first-writer-wins per cell.SnapshotStoreimplementsJournalTarget(exportEntityCell/exportRelationCell/exportHasManyCell+applyJournalImages) and gainsbeginTransaction/commitTransaction/transaction(); mutating methods record before writing.ActionDispatcher.dispatchand the pre-create handle gestures (HasManyListHandle.add,HasOneHandle.create) open transactions → one gesture = one undo unit.UndoManagerbecomes a thin policy layer (debounce/manual grouping, block during persist, rekey-of-stacks); thecreateMiddleware()API is preserved.actionClassificationstatic projection.Tests — 36 green
tests/undo.test.ts— original suite, unchanged assertions.tests/undo-stabilization.test.ts— the 3 characterization bugs (written failing-first), plus a scale guard (entry is O(edit), not O(store)) and the handle-gesture round-trip.tests/undo-journal.test.ts— deep coverage: seal across persist incl. the create-across-save falsification (create C under saved P + edit sibling S in one group → undo reverts S, keeps C) and the explicit-ordering edge, rekey embedded-id remap (has-many + has-one), has-many move/disconnect/delete undo, multi-cell atomic gesture, redo-after-persist, absent-relation restore, nested field, schedule-delete, group first-writer-wins.Writing the deep tests revealed and fixed a real bug: explicit-ordering create-across-save dropped the persisted child on undo → fixed with the membership rebase (
SnapshotStore.getLiveHasManyServerIds).Validation
@contember/bindxcore + all consumer packages (react/form/dataview/ui) typecheck clean.Targets
refactor/relationstore-decompositionsince it builds on that decomposition.🤖 Generated with Claude Code