feat(notes): operation log, block leases and sync API for collaborative editing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
Phase 1 of the collaboration plan (server side):
- internal/collab: block tree with parent IDs and fractional order keys,
operations with conflict rules (version check, block leases, conflict
copies instead of lost text, restore on late edits, cycle checks),
in-memory leases/sessions and long-poll notifier.
- Storage: per-note lock + transaction, append-only effect log (doc_ops),
idempotent results per opId (doc_op_results), snapshot, log paging,
central DocRole. Old flat block data is converted to the tree.
- The existing whole-document save (PUT/PATCH) runs as an adapter through
the same engine, so the current editor keeps working and writes to the log.
- API: GET /docs/{id}/snapshot, POST /docs/{id}/sync, GET /docs/{id}/changes
(long poll), POST /docs/{id}/leave; DOC_SEQ poke for WebSocket/SSE rooms.
- Backend accepts editor (write) shares for notes and folders; the forced
read-only migration is gone. The share dialog still offers read only.
- Tests: conflict matrix, order keys, leases, storage adapter round trip,
idempotency, API protocol incl. long poll, and a randomized simulation
(4 clients, lost/duplicated requests and responses, offline phases,
lease expiry) checking convergence, no lost text and exactly-once results.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bcc06da3ee
commit
1ea0c78b41
23 files changed
+3266
-287
No files matched your search
@@ -0,0 +1,263 @@
|
||||
package collab
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
anna = Actor{UserID: "u_anna", ClientID: "c_anna", Name: "Anna"}
|
||||
ben = Actor{UserID: "u_ben", ClientID: "c_ben", Name: "Ben"}
|
||||
)
|
||||
|
||||
func str(s string) *string { return &s }
|
||||
|
||||
type fixedLeases map[string]string
|
||||
|
||||
func (f fixedLeases) HolderOf(id string) (string, bool) { c, ok := f[id]; return c, ok }
|
||||
|
||||
// docWith builds a doc via real operations so versions and keys are realistic.
|
||||
func docWith(t *testing.T, blocks ...[3]string) *Doc { // {id, parent, content}
|
||||
t.Helper()
|
||||
d := NewDoc()
|
||||
keys := map[string]string{}
|
||||
for i, b := range blocks {
|
||||
k, _ := KeyBetween(keys[b[1]], "")
|
||||
keys[b[1]] = k
|
||||
_, res := Apply(d, Op{OpID: "seed" + string(rune('a'+i)), Kind: OpInsert, BlockID: b[0], ParentID: str(b[1]), OrderKey: k, Content: str(b[2])}, anna, Env{})
|
||||
if res.Status != StatusApplied {
|
||||
t.Fatalf("seed %v: %+v", b, res)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustValid(t *testing.T, d *Doc) {
|
||||
t.Helper()
|
||||
if err := d.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetContentAppliesOnMatchingVersion(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Hallo"})
|
||||
_, res := Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Hallo Welt")}, anna, Env{})
|
||||
if res.Status != StatusApplied || d.Blocks["a"].Content != "Hallo Welt" || d.Blocks["a"].Version != 2 {
|
||||
t.Fatalf("got %+v, block %+v", res, d.Blocks["a"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleVersionCreatesConflictCopy(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Start"}, [3]string{"b", "", "Danach"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Von Anna")}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Von Ben")}, ben, Env{})
|
||||
|
||||
if res.Status != StatusConflict || res.ConflictBlockID == "" {
|
||||
t.Fatalf("want conflict, got %+v", res)
|
||||
}
|
||||
if d.Blocks["a"].Content != "Von Anna" {
|
||||
t.Fatal("first writer must keep the block")
|
||||
}
|
||||
flat := d.Flatten()
|
||||
if len(flat) != 3 || flat[1].ID != res.ConflictBlockID || flat[1].Content != "Von Ben" || flat[2].ID != "b" {
|
||||
t.Fatalf("conflict copy must sit directly below the block: %+v", flat)
|
||||
}
|
||||
if c := d.Blocks[res.ConflictBlockID].Props; c.ConflictOf != "a" || c.ConflictAuthor != "Ben" {
|
||||
t.Fatalf("conflict props wrong: %+v", c)
|
||||
}
|
||||
mustValid(t, d)
|
||||
}
|
||||
|
||||
func TestForeignLeaseCreatesConflictCopy(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Start"})
|
||||
_, res := Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Ben tippt")}, ben, Env{Leases: fixedLeases{"a": "c_anna"}})
|
||||
if res.Status != StatusConflict || d.Blocks["a"].Content != "Start" {
|
||||
t.Fatalf("lease holder must win: %+v", res)
|
||||
}
|
||||
// The lease holder itself may write
|
||||
_, res = Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Anna tippt")}, anna, Env{Leases: fixedLeases{"a": "c_anna"}})
|
||||
if res.Status != StatusApplied {
|
||||
t.Fatalf("lease holder rejected: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdenticalContentIsNoConflict(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Gleich"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Neu")}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Neu")}, ben, Env{})
|
||||
if res.Status != StatusApplied || len(d.Blocks) != 1 {
|
||||
t.Fatalf("same text must not produce a conflict copy: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditOnDeletedBlockRestoresIt(t *testing.T) {
|
||||
d := docWith(t, [3]string{"p", "", "Eltern"}, [3]string{"c", "p", "Kind"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "p"}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "c", BaseVersion: 1, Content: str("Spät getippt")}, ben, Env{})
|
||||
if res.Status != StatusApplied {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
c := d.Blocks["c"]
|
||||
if c.Deleted || c.Content != "Spät getippt" || c.ParentID != "" {
|
||||
t.Fatalf("block must be restored at the nearest live ancestor: %+v", c)
|
||||
}
|
||||
mustValid(t, d)
|
||||
}
|
||||
|
||||
func TestDeleteIsBlockedByForeignLeaseInSubtree(t *testing.T) {
|
||||
d := docWith(t, [3]string{"p", "", "Ast"}, [3]string{"c", "p", "Unterpunkt"})
|
||||
_, res := Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "p"}, anna, Env{Leases: fixedLeases{"c": "c_ben"}})
|
||||
if res.Status != StatusRejected || res.Reason != "locked" || d.Blocks["p"].Deleted {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
_, res = Apply(d, Op{OpID: "2", Kind: OpDelete, BlockID: "p"}, anna, Env{})
|
||||
if res.Status != StatusApplied || !d.Blocks["c"].Deleted {
|
||||
t.Fatal("delete must tombstone the whole subtree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRejectsCyclesAndLockedBlocks(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
|
||||
if _, res := Apply(d, Op{OpID: "1", Kind: OpMove, BlockID: "a", ParentID: str("b"), OrderKey: "a0"}, anna, Env{}); res.Reason != "cycle" {
|
||||
t.Fatalf("want cycle, got %+v", res)
|
||||
}
|
||||
if _, res := Apply(d, Op{OpID: "2", Kind: OpMove, BlockID: "b", ParentID: str(""), OrderKey: "a5"}, anna, Env{Leases: fixedLeases{"b": "c_ben"}}); res.Reason != "locked" {
|
||||
t.Fatalf("want locked, got %+v", res)
|
||||
}
|
||||
if _, res := Apply(d, Op{OpID: "3", Kind: OpMove, BlockID: "b", ParentID: str(""), OrderKey: "a5"}, anna, Env{}); res.Status != StatusApplied {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
mustValid(t, d)
|
||||
}
|
||||
|
||||
func TestInsertIntoDeletedParentGoesToLiveAncestor(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "b"}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpInsert, BlockID: "n", ParentID: str("b"), OrderKey: "a0", Content: str("neu")}, ben, Env{})
|
||||
if res.Status != StatusApplied || d.Blocks["n"].ParentID != "a" {
|
||||
t.Fatalf("got %+v parent=%q", res, d.Blocks["n"].ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertRejectsTakenIDs(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"})
|
||||
if _, res := Apply(d, Op{OpID: "1", Kind: OpInsert, BlockID: "a", OrderKey: "a5"}, anna, Env{}); res.Reason != "exists" {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
env := Env{IDTaken: func(id string) bool { return id == "other_doc_block" }}
|
||||
if _, res := Apply(d, Op{OpID: "2", Kind: OpInsert, BlockID: "other_doc_block", OrderKey: "a5"}, anna, env); res.Reason != "exists" {
|
||||
t.Fatalf("block IDs of other documents must be rejected: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleConflictBecomesTopBlock(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Erster"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetTitle, BaseVersion: 0, Title: str("Titel Anna")}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetTitle, BaseVersion: 0, Title: str("Titel Ben")}, ben, Env{})
|
||||
if res.Status != StatusConflict || d.Title != "Titel Anna" {
|
||||
t.Fatalf("got %+v title=%q", res, d.Title)
|
||||
}
|
||||
if flat := d.Flatten(); flat[0].ID != res.ConflictBlockID || flat[0].Props.ConflictOf != "title" {
|
||||
t.Fatalf("title conflict must be the first block: %+v", flat[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPropsDoesNotBumpContentVersion(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Todo"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetProps, BlockID: "a", Patch: &PropsPatch{Checked: new(bool)}}, anna, Env{})
|
||||
yes := true
|
||||
Apply(d, Op{OpID: "2", Kind: OpSetProps, BlockID: "a", Patch: &PropsPatch{Checked: &yes}}, ben, Env{Leases: fixedLeases{"a": "c_anna"}})
|
||||
if !d.Blocks["a"].Props.Checked || d.Blocks["a"].Version != 1 {
|
||||
t.Fatalf("props must apply independently of text and leases: %+v", d.Blocks["a"])
|
||||
}
|
||||
}
|
||||
|
||||
// Replaying the effects of every operation on an empty doc must give exactly the same state.
|
||||
func TestEffectsReplayToSameState(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
|
||||
replay := d.Clone()
|
||||
ops := []struct {
|
||||
op Op
|
||||
a Actor
|
||||
}{
|
||||
{Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A2")}, anna},
|
||||
{Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A3")}, ben},
|
||||
{Op{OpID: "3", Kind: OpDelete, BlockID: "a"}, anna},
|
||||
{Op{OpID: "4", Kind: OpSetContent, BlockID: "b", BaseVersion: 1, Content: str("B2")}, ben},
|
||||
{Op{OpID: "5", Kind: OpSetTitle, Title: str("T")}, anna},
|
||||
}
|
||||
for _, o := range ops {
|
||||
effects, _ := Apply(d, o.op, o.a, Env{})
|
||||
for _, e := range effects {
|
||||
replay.ApplyEffect(e)
|
||||
}
|
||||
}
|
||||
if !docsEqual(d, replay) {
|
||||
t.Fatal("replayed effects differ from server state")
|
||||
}
|
||||
}
|
||||
|
||||
func docsEqual(a, b *Doc) bool {
|
||||
if a.Title != b.Title || a.TitleVersion != b.TitleVersion || len(a.Blocks) != len(b.Blocks) {
|
||||
return false
|
||||
}
|
||||
for id, x := range a.Blocks {
|
||||
y, ok := b.Blocks[id]
|
||||
if !ok || *x != *y {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestLeaseManager(t *testing.T) {
|
||||
now := time.Unix(1000, 0)
|
||||
m := NewLeaseManager(func() time.Time { return now })
|
||||
|
||||
g, den := m.Hold("d", anna, []string{"a", "b"})
|
||||
if len(g) != 2 || len(den) != 0 {
|
||||
t.Fatalf("anna: granted=%v denied=%v", g, den)
|
||||
}
|
||||
if g, den = m.Hold("d", ben, []string{"a", "c"}); len(g) != 1 || den[0] != "a" {
|
||||
t.Fatalf("ben: granted=%v denied=%v", g, den)
|
||||
}
|
||||
if others := m.Others("d", "c_ben"); len(others) != 2 || others[0].Name != "Anna" {
|
||||
t.Fatalf("others: %+v", others)
|
||||
}
|
||||
|
||||
now = now.Add(LeaseTTL + time.Second)
|
||||
if g, _ = m.Hold("d", ben, []string{"a"}); len(g) != 1 {
|
||||
t.Fatal("expired lease must be available")
|
||||
}
|
||||
|
||||
m.ReleaseAllExcept("d", "c_ben", []string{"a"})
|
||||
if _, held := m.View("d").HolderOf("c"); held {
|
||||
t.Fatal("lease not in keep set must be released")
|
||||
}
|
||||
|
||||
m.Touch("d", anna)
|
||||
m.Touch("d", ben)
|
||||
if n, people := m.Sessions("d"); n != 2 || len(people) != 2 {
|
||||
t.Fatalf("sessions: %d %+v", n, people)
|
||||
}
|
||||
m.Leave("d", "c_ben")
|
||||
if _, held := m.View("d").HolderOf("a"); held {
|
||||
t.Fatal("leaving must drop the client's leases")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier(t *testing.T) {
|
||||
n := NewNotifier()
|
||||
ch := n.Changed("d")
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("must not fire before Notify")
|
||||
default:
|
||||
}
|
||||
n.Notify("d")
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Notify must wake waiters")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user