Skip to content

refactor(bindx): rebuild undo/redo as a write-journal over the decomposed store#50

Open
matej21 wants to merge 28 commits into
refactor/relationstore-decompositionfrom
feat/undo-write-journal
Open

refactor(bindx): rebuild undo/redo as a write-journal over the decomposed store#50
matej21 wants to merge 28 commits into
refactor/relationstore-decompositionfrom
feat/undo-write-journal

Conversation

@matej21

@matej21 matej21 commented Jun 24, 2026

Copy link
Copy Markdown
Member

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:

  1. Created entities in a list weren't captured. A list-add creates the child as part of the gesture, but the projection only returned the parent has-many key. The add→undo→redo round-trip survived only by accident (the orphan lingered); once the memory sweep reclaimed it, redo restored list membership but the child data was gone.
  2. RootRegistry was never captured. A top-level created entity's "pending create" status is anchored solely by its root membership → phantom/lost creates after undo.
  3. Undo didn't survive a temp→persisted rekey. Stored pre-images held the temp key; after persist rekeyed the live entity, undo wrote back under the stale key (zombie) and left the persisted entity untouched.

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.

  • 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 → one gesture = one undo unit.
  • Editable-layer splice: restore writes only the editable layer onto the live server baseline, so undoing a persisted edit re-dirties against the current baseline instead of resurrecting a stale one.
  • 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

  • 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

  • Full suite: 1672 pass, 10 fail — all 10 pre-existing and environmental (9 browser tests need the playground on :15180; 1 form test verified failing on the base commit). Zero regressions.
  • @contember/bindx core + all consumer packages (react/form/dataview/ui) typecheck clean.

Targets refactor/relationstore-decomposition since it builds on that decomposition.

🤖 Generated with Claude Code

matej21 and others added 22 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
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
@matej21 matej21 force-pushed the feat/undo-write-journal branch from 6cb7d77 to 52bcf43 Compare June 24, 2026 11:44
matej21 and others added 3 commits July 1, 2026 16:23
…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
@matej21 matej21 force-pushed the feat/undo-write-journal branch from 52bcf43 to 3abef4b Compare July 1, 2026 14:30
matej21 and others added 3 commits July 2, 2026 13:41
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
@matej21 matej21 force-pushed the refactor/relationstore-decomposition branch from f019160 to 02d7a79 Compare July 2, 2026 13:33
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