From c6acfa680622d8e75e05510d8cf1858bffe6eceb Mon Sep 17 00:00:00 2001 From: Bruno Albuquerque Date: Sat, 28 Mar 2026 06:46:01 -0400 Subject: [PATCH] feat(crdt): add Reverse method to CRDT[T] for undo/redo support --- CHANGELOG.md | 2 +- README.md | 15 +++++++++++++++ crdt/crdt.go | 14 ++++++++++++++ crdt/crdt_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72c87e..8c790ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,7 @@ Public package used directly by generated `*_deep.go` files. Most callers will n ### CRDTs (`github.com/brunoga/deep/v5/crdt`) -- `CRDT[T]` — Concurrency-safe CRDT wrapper. Create with `NewCRDT(initial, nodeID)`. Key methods: `Edit(fn)`, `ApplyDelta(delta)`, `Merge(other)`, `View()`. JSON-serializable. +- `CRDT[T]` — Concurrency-safe CRDT wrapper. Create with `NewCRDT(initial, nodeID)`. Key methods: `Edit(fn)`, `ApplyDelta(delta)`, `Merge(other)`, `Reverse(delta)`, `View()`. JSON-serializable. `Reverse` applies the inverse of a delta and returns a new undo delta with a fresh HLC timestamp; calling `Reverse` on that delta produces a redo. - `Delta[T]` — A timestamped set of changes produced by `CRDT.Edit`; send to peers and apply with `CRDT.ApplyDelta`. - `LWW[T]` — Embeddable Last-Write-Wins register. Update with `Set(v, ts)`; accepts write only if `ts` is strictly newer. - `Text` (`[]TextRun`) — Convergent collaborative text. Merge concurrent edits with `MergeTextRuns(a, b)`. diff --git a/README.md b/README.md index 803c697..b537dd7 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,21 @@ undo := patch.Reverse() strictPatch := patch.AsStrict() ``` +### Undo/Redo with CRDTs + +`CRDT[T].Reverse` applies the inverse of a delta to the local node and returns a +new `Delta[T]` with a fresh HLC timestamp, making it causally after the original +edit and safe to propagate to peers: + +```go +// Apply an edit, then undo it. +delta, _ := node.Edit(func(v *MyDoc) { v.Title = "Draft" }) +undo := node.Reverse(delta) // applies inverse locally, returns undo delta + +// Redo: reverse the undo. +redo := node.Reverse(undo) +``` + ### Standard Interop Export your Deep patches to standard RFC 6902 JSON Patch format, and parse them back: diff --git a/crdt/crdt.go b/crdt/crdt.go index f275f9f..9eea3b2 100644 --- a/crdt/crdt.go +++ b/crdt/crdt.go @@ -227,6 +227,20 @@ func textAncestorPath(root reflect.Value, opPath string) (string, bool) { return "", false } +// Reverse applies the inverse of delta to this node and returns a new Delta +// representing the undo operation. The returned Delta carries a fresh HLC +// timestamp so it is causally after the original edit and will be accepted by +// ApplyDelta on any peer that has already seen the original. +// +// Calling Reverse on the returned Delta produces a redo Delta. +func (c *CRDT[T]) Reverse(delta Delta[T]) Delta[T] { + reversed := delta.patch.Reverse() + now := c.clock.Now() + undoDelta := Delta[T]{patch: reversed, Timestamp: now} + c.ApplyDelta(undoDelta) + return undoDelta +} + // Merge performs a full state-based merge with another CRDT node. // For each changed field the node with the strictly newer effective timestamp // (max of write clock and tombstone) wins. Text fields are always merged diff --git a/crdt/crdt_test.go b/crdt/crdt_test.go index 74303ba..d46239b 100644 --- a/crdt/crdt_test.go +++ b/crdt/crdt_test.go @@ -282,6 +282,32 @@ func TestMerge_TextInKeyedSliceElement(t *testing.T) { } } +func TestCRDT_Reverse(t *testing.T) { + nodeA := NewCRDT(TestUser{ID: 1, Name: "Alice"}, "node-a") + nodeB := NewCRDT(TestUser{ID: 1, Name: "Alice"}, "node-b") + + delta := nodeA.Edit(func(u *TestUser) { u.Name = "Bob" }) + + // Undo on nodeA + undoDelta := nodeA.Reverse(delta) + if nodeA.View().Name != "Alice" { + t.Errorf("Reverse did not undo: expected Alice, got %s", nodeA.View().Name) + } + + // Undo delta propagates to peers + nodeB.ApplyDelta(delta) + nodeB.ApplyDelta(undoDelta) + if nodeB.View().Name != "Alice" { + t.Errorf("Reverse delta did not propagate: expected Alice, got %s", nodeB.View().Name) + } + + // Redo: reverse the undo + nodeA.Reverse(undoDelta) + if nodeA.View().Name != "Bob" { + t.Errorf("Redo failed: expected Bob, got %s", nodeA.View().Name) + } +} + func TestCRDT_JSON(t *testing.T) { node := NewCRDT(TestUser{ID: 1, Name: "Initial"}, "node1") node.Edit(func(u *TestUser) {