Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions crdt/crdt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions crdt/crdt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading