History-derived undo plans#175
Open
marcus-pousette wants to merge 4 commits into
Open
Conversation
0e02045 to
e87f392
Compare
e87f392 to
44c71e7
Compare
051a0d6 to
1e570cb
Compare
This was referenced Jun 23, 2026
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.
Summary
This PR adds a TreeCRDT edit API for app-level undo/redo.
edits.capture(...)records the local operations produced by one user edit.edits.undo(...)turns a captured edit into new local TreeCRDT operations that reverse the visible effect.edits.redo(...)reapplies an undone edit and returns a fresh undo plan.Undo/redo stacks are still owned by the app. TreeCRDT provides the operations and plans; the app decides what counts as one user edit, how many edits to keep, when to clear redo, and whether to persist the stack.
How Undo/Redo Works
Undo does not delete or rewrite old operations. It is closer to
git revert: TreeCRDT looks at the state around the captured edit, creates a new forward local edit that restores the previous visible state, and appends that new edit to the oplog.Redo works the same way in the other direction. Undo returns a redo plan, redo returns a new undo plan, so chains like undo/undo/redo/redo keep working as normal local writes.
Because undo and redo are normal TreeCRDT writes, existing sync, merge behavior, materialization events, and write ids continue to work.
Cost Model
A naive undo implementation would capture inverse metadata during every normal write. That means inserts, moves, deletes, and payload writes may need extra reads of parents, siblings, or previous payloads even when the user never presses undo.
This PR keeps the normal write path lighter:
edits.capture(...)only records the operations produced by the edit. The more expensive work is deferred until undo time.For SQLite-backed engines, history inversion first tries a suffix rewind from the captured edit frontier inside a rollbackable savepoint. For recent edits, this is bounded by the number of operations since that edit, not the full oplog. If the fast path cannot prove the case safely, it falls back to canonical replay from history, which is slower but conservative.
So the tradeoff is: cheap common writes, potentially more work on rare undo, with a correctness-first fallback.
Implementation Support
@treecrdt/sqlite-nodeengine.history.invertthrough the SQLite extension path.@treecrdt/wa-sqlite@treecrdt/postgres-napiedits.undo(...)on a lazy captured edit rejects because the engine does not exposeengine.history.edits.capturePlan(...), which captures undo metadata immediately on the write path.The conformance suite covers eager undo/redo everywhere it applies, history-derived undo/redo for engines that expose
engine.history, repeated undo/redo chains, new writes after undo, imported operations between capture and undo, and materialization event/writeId behavior.