Skip to content

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

Open
matej21 wants to merge 1 commit 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 1 commit 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

…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 6cb7d77 to 52bcf43 Compare June 24, 2026 11:44
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