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

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:
sandrodachandClaude Opus 5.5 committed 2026-10-28 11:47:56 +01:00
1 parent bcc06da3ee
commit 1ea0c78b41
23 files changed
+3266 -287

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
// Package collab contains the database-independent core of collaborative note editing: the block tree,
// operations with their conflict rules, block leases and change notification. The same rules run on the
// server for every incoming operation; clients replay the resulting effects to stay in sync.
package collab
import (
"fmt"
"sort"
)
// Props are per-block settings that change independently of the text.
type Props struct {
Checked bool `json:"checked,omitempty"`
Collapsed bool `json:"collapsed,omitempty"`
Lang string `json:"lang,omitempty"`
ConflictOf string `json:"conflictOf,omitempty"` // ID of the block (or "title") this conflict copy belongs to
ConflictAuthor string `json:"conflictAuthor,omitempty"` // display name of the author whose text lost
}
type Block struct {
ID string `json:"id"`
ParentID string `json:"parentId"` // "" = top level
OrderKey string `json:"orderKey"`
Type string `json:"type"`
Content string `json:"content"`
Props Props `json:"props"`
Version int `json:"version"` // content version; props changes do not bump it
Deleted bool `json:"deleted,omitempty"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
// Doc is the complete state of one note including deleted blocks (tombstones).
type Doc struct {
Title string
TitleVersion int
Blocks map[string]*Block
}
func NewDoc() *Doc {
return &Doc{Blocks: make(map[string]*Block)}
}
func (d *Doc) Clone() *Doc {
c := &Doc{Title: d.Title, TitleVersion: d.TitleVersion, Blocks: make(map[string]*Block, len(d.Blocks))}
for id, b := range d.Blocks {
cp := *b
c.Blocks[id] = &cp
}
return c
}
func siblingLess(a, b *Block) bool {
if a.OrderKey != b.OrderKey {
return a.OrderKey < b.OrderKey
}
return a.ID < b.ID
}
// Children returns the live children of parentID in display order.
func (d *Doc) Children(parentID string) []*Block {
out := make([]*Block, 0)
for _, b := range d.Blocks {
if !b.Deleted && b.ParentID == parentID {
out = append(out, b)
}
}
sort.Slice(out, func(i, j int) bool { return siblingLess(out[i], out[j]) })
return out
}
// FlatBlock is a live block with its depth, in document (depth-first) order.
type FlatBlock struct {
*Block
Level int
}
// Flatten returns all live blocks in display order with their nesting level.
func (d *Doc) Flatten() []FlatBlock {
children := make(map[string][]*Block)
for _, b := range d.Blocks {
if !b.Deleted {
children[b.ParentID] = append(children[b.ParentID], b)
}
}
for _, list := range children {
sort.Slice(list, func(i, j int) bool { return siblingLess(list[i], list[j]) })
}
out := make([]FlatBlock, 0, len(d.Blocks))
var walk func(parent string, level int)
walk = func(parent string, level int) {
for _, b := range children[parent] {
out = append(out, FlatBlock{Block: b, Level: level})
walk(b.ID, level+1)
}
}
walk("", 0)
return out
}
// subtree returns id and all descendants; with liveOnly, deleted blocks are skipped.
func (d *Doc) subtree(id string, liveOnly bool) []string {
children := make(map[string][]string)
for _, b := range d.Blocks {
if liveOnly && b.Deleted {
continue
}
children[b.ParentID] = append(children[b.ParentID], b.ID)
}
out := []string{id}
for i := 0; i < len(out); i++ {
out = append(out, children[out[i]]...)
}
sort.Strings(out[1:])
return out
}
// liveAncestor walks up from id until it finds a live block; "" means top level.
func (d *Doc) liveAncestor(id string) string {
seen := map[string]bool{}
for id != "" && !seen[id] {
seen[id] = true
b, ok := d.Blocks[id]
if !ok {
return ""
}
if !b.Deleted {
return id
}
id = b.ParentID
}
return ""
}
// isAncestor reports whether ancestorID is ancestorID itself or above id in the tree.
func (d *Doc) isAncestor(ancestorID, id string) bool {
seen := map[string]bool{}
for id != "" && !seen[id] {
if id == ancestorID {
return true
}
seen[id] = true
b, ok := d.Blocks[id]
if !ok {
return false
}
id = b.ParentID
}
return false
}
// keyAfter returns a key directly after block b among its live siblings.
func (d *Doc) keyAfter(b *Block) string {
next := ""
for _, s := range d.Children(b.ParentID) {
if siblingLess(b, s) {
next = s.OrderKey
break
}
}
if next == b.OrderKey {
// Equal keys are only ordered by ID; append after the whole run instead
next = ""
for _, s := range d.Children(b.ParentID) {
if s.OrderKey > b.OrderKey {
next = s.OrderKey
break
}
}
}
k, err := KeyBetween(b.OrderKey, next)
if err != nil {
k, _ = KeyBetween(b.OrderKey, "")
}
return k
}
// Validate checks the tree invariants: parents exist, live blocks have live parents, no cycles, valid keys.
func (d *Doc) Validate() error {
for id, b := range d.Blocks {
if b.ID != id {
return fmt.Errorf("block %s stored under key %s", b.ID, id)
}
if !ValidOrderKey(b.OrderKey) {
return fmt.Errorf("block %s has invalid order key %q", id, b.OrderKey)
}
if b.ParentID == "" {
continue
}
p, ok := d.Blocks[b.ParentID]
if !ok {
return fmt.Errorf("block %s has missing parent %s", id, b.ParentID)
}
if !b.Deleted && p.Deleted {
return fmt.Errorf("live block %s has deleted parent %s", id, p.ID)
}
if d.isAncestor(id, b.ParentID) {
return fmt.Errorf("block %s is part of a cycle", id)
}
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package collab
import (
"sort"
"sync"
"time"
)
const (
LeaseTTL = 20 * time.Second
SessionTimeout = 30 * time.Second
)
type lease struct {
clientID string
userID string
name string
expires time.Time
}
type session struct {
userID string
name string
lastSeen time.Time
}
// LeaseInfo describes a block held by another client, for display ("Anna bearbeitet …").
type LeaseInfo struct {
BlockID string `json:"blockId"`
UserID string `json:"userId"`
Name string `json:"name"`
}
// Presence is one client that synced a document recently.
type Presence struct {
UserID string `json:"userId"`
Name string `json:"name"`
}
// LeaseManager keeps block leases and active sessions in memory. Leases are short-lived; after a server
// restart clients simply request them again with their next sync.
type LeaseManager struct {
mu sync.Mutex
now func() time.Time
leases map[string]map[string]lease // docID -> blockID -> lease
sessions map[string]map[string]session // docID -> clientID -> session
}
func NewLeaseManager(now func() time.Time) *LeaseManager {
if now == nil {
now = time.Now
}
return &LeaseManager{now: now, leases: map[string]map[string]lease{}, sessions: map[string]map[string]session{}}
}
func (m *LeaseManager) docLeases(docID string) map[string]lease {
l := m.leases[docID]
if l == nil {
l = map[string]lease{}
m.leases[docID] = l
}
return l
}
func (m *LeaseManager) prune(docID string, now time.Time) {
for id, l := range m.leases[docID] {
if !now.Before(l.expires) {
delete(m.leases[docID], id)
}
}
for cid, s := range m.sessions[docID] {
if now.Sub(s.lastSeen) > SessionTimeout {
delete(m.sessions[docID], cid)
}
}
}
// Hold acquires or renews leases for a client. A block held by another live client is denied.
func (m *LeaseManager) Hold(docID string, a Actor, blockIDs []string) (granted, denied []string) {
m.mu.Lock()
defer m.mu.Unlock()
now := m.now()
m.prune(docID, now)
l := m.docLeases(docID)
for _, id := range blockIDs {
if cur, ok := l[id]; ok && cur.clientID != a.ClientID {
denied = append(denied, id)
continue
}
l[id] = lease{clientID: a.ClientID, userID: a.UserID, name: a.Name, expires: now.Add(LeaseTTL)}
granted = append(granted, id)
}
return granted, denied
}
// Release gives up leases held by this client.
func (m *LeaseManager) Release(docID, clientID string, blockIDs []string) {
m.mu.Lock()
defer m.mu.Unlock()
l := m.leases[docID]
for _, id := range blockIDs {
if cur, ok := l[id]; ok && cur.clientID == clientID {
delete(l, id)
}
}
}
// ReleaseAllExcept drops every lease of the client in this document that is not in keep.
func (m *LeaseManager) ReleaseAllExcept(docID, clientID string, keep []string) {
m.mu.Lock()
defer m.mu.Unlock()
keepSet := make(map[string]bool, len(keep))
for _, id := range keep {
keepSet[id] = true
}
for id, cur := range m.leases[docID] {
if cur.clientID == clientID && !keepSet[id] {
delete(m.leases[docID], id)
}
}
}
// View returns a snapshot of the current leases of a document for operation validation.
func (m *LeaseManager) View(docID string) LeaseView {
m.mu.Lock()
defer m.mu.Unlock()
now := m.now()
m.prune(docID, now)
snap := make(leaseSnapshot, len(m.leases[docID]))
for id, l := range m.leases[docID] {
snap[id] = l.clientID
}
return snap
}
// Others lists blocks held by clients other than clientID.
func (m *LeaseManager) Others(docID, clientID string) []LeaseInfo {
m.mu.Lock()
defer m.mu.Unlock()
m.prune(docID, m.now())
out := make([]LeaseInfo, 0)
for id, l := range m.leases[docID] {
if l.clientID != clientID {
out = append(out, LeaseInfo{BlockID: id, UserID: l.userID, Name: l.name})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].BlockID < out[j].BlockID })
return out
}
// Touch records that a client synced the document; it counts as an open session for SessionTimeout.
func (m *LeaseManager) Touch(docID string, a Actor) {
m.mu.Lock()
defer m.mu.Unlock()
s := m.sessions[docID]
if s == nil {
s = map[string]session{}
m.sessions[docID] = s
}
s[a.ClientID] = session{userID: a.UserID, name: a.Name, lastSeen: m.now()}
}
// Leave removes a client's session and all its leases (tab closed, app sent to background).
func (m *LeaseManager) Leave(docID, clientID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions[docID], clientID)
for id, l := range m.leases[docID] {
if l.clientID == clientID {
delete(m.leases[docID], id)
}
}
}
// Sessions returns how many clients have the document open and who they are.
func (m *LeaseManager) Sessions(docID string) (int, []Presence) {
m.mu.Lock()
defer m.mu.Unlock()
m.prune(docID, m.now())
seen := map[string]bool{}
people := make([]Presence, 0)
for _, s := range m.sessions[docID] {
if !seen[s.userID] {
seen[s.userID] = true
people = append(people, Presence{UserID: s.userID, Name: s.name})
}
}
sort.Slice(people, func(i, j int) bool { return people[i].UserID < people[j].UserID })
return len(m.sessions[docID]), people
}
type leaseSnapshot map[string]string
func (s leaseSnapshot) HolderOf(blockID string) (string, bool) {
c, ok := s[blockID]
return c, ok
}
+34
View File
@@ -0,0 +1,34 @@
package collab
import "sync"
// Notifier wakes up long-poll requests waiting for changes of a document.
type Notifier struct {
mu sync.Mutex
wait map[string]chan struct{}
}
func NewNotifier() *Notifier {
return &Notifier{wait: map[string]chan struct{}{}}
}
// Changed returns a channel that is closed on the next Notify for docID.
func (n *Notifier) Changed(docID string) <-chan struct{} {
n.mu.Lock()
defer n.mu.Unlock()
ch, ok := n.wait[docID]
if !ok {
ch = make(chan struct{})
n.wait[docID] = ch
}
return ch
}
func (n *Notifier) Notify(docID string) {
n.mu.Lock()
defer n.mu.Unlock()
if ch, ok := n.wait[docID]; ok {
close(ch)
delete(n.wait, docID)
}
}
+396
View File
@@ -0,0 +1,396 @@
package collab
import (
"crypto/sha256"
"encoding/hex"
"sort"
)
const (
OpInsert = "insert"
OpSetContent = "set_content"
OpSetProps = "set_props"
OpMove = "move"
OpDelete = "delete"
OpRestore = "restore"
OpSetTitle = "set_title"
StatusApplied = "applied"
StatusConflict = "conflict"
StatusRejected = "rejected"
maxContentLength = 100_000
maxTypeLength = 20
)
// PropsPatch changes selected block settings; nil fields stay as they are.
type PropsPatch struct {
Type *string `json:"type,omitempty"`
Checked *bool `json:"checked,omitempty"`
Collapsed *bool `json:"collapsed,omitempty"`
Lang *string `json:"lang,omitempty"`
}
// Op is a change requested by a client. OpID is chosen by the client and makes retries idempotent.
type Op struct {
OpID string `json:"opId"`
Kind string `json:"kind"`
BlockID string `json:"blockId,omitempty"`
BaseVersion int `json:"baseVersion,omitempty"` // set_content, set_title
ParentID *string `json:"parentId,omitempty"` // insert, move ("" = top level)
OrderKey string `json:"orderKey,omitempty"` // insert, move
Type string `json:"type,omitempty"` // insert
Content *string `json:"content,omitempty"` // insert, set_content
Props *Props `json:"props,omitempty"` // insert
Patch *PropsPatch `json:"patch,omitempty"` // set_props
IDs []string `json:"ids,omitempty"` // restore
Title *string `json:"title,omitempty"` // set_title
}
// Effect is a normalized, already validated change. The server log stores effects; replaying them in
// order on an empty Doc reproduces the server state exactly.
type Effect struct {
Kind string `json:"kind"`
BlockID string `json:"blockId,omitempty"`
ParentID string `json:"parentId,omitempty"`
OrderKey string `json:"orderKey,omitempty"`
Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"`
Props *Props `json:"props,omitempty"`
Version int `json:"version,omitempty"`
IDs []string `json:"ids,omitempty"`
Title string `json:"title,omitempty"`
TitleVersion int `json:"titleVersion,omitempty"`
By string `json:"by,omitempty"`
}
type Result struct {
OpID string `json:"opId"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Version int `json:"version,omitempty"`
ConflictBlockID string `json:"conflictBlockId,omitempty"`
Seq int64 `json:"seq,omitempty"`
}
// Actor identifies who sends an operation. ClientID is one browser tab / sync engine instance.
type Actor struct {
UserID string
ClientID string
Name string
}
// LeaseView answers which client currently holds the lease on a block.
type LeaseView interface {
HolderOf(blockID string) (clientID string, held bool)
}
// Env provides outside knowledge needed to validate operations.
type Env struct {
Leases LeaseView // nil = no leases (e.g. server-internal writes)
IDTaken func(id string) bool // true if the block ID exists in another document
}
// ApplyEffect mutates the document with one effect. It never fails; effects are validated by Apply.
func (d *Doc) ApplyEffect(e Effect) {
switch e.Kind {
case OpInsert:
b := &Block{ID: e.BlockID, ParentID: e.ParentID, OrderKey: e.OrderKey, Type: e.Type, Content: e.Content, Version: e.Version, UpdatedBy: e.By}
if e.Props != nil {
b.Props = *e.Props
}
d.Blocks[e.BlockID] = b
case OpSetContent:
if b, ok := d.Blocks[e.BlockID]; ok {
b.Content, b.Version, b.UpdatedBy = e.Content, e.Version, e.By
}
case OpSetProps:
if b, ok := d.Blocks[e.BlockID]; ok {
b.Type = e.Type
if e.Props != nil {
b.Props = *e.Props
}
}
case OpMove:
if b, ok := d.Blocks[e.BlockID]; ok {
b.ParentID, b.OrderKey = e.ParentID, e.OrderKey
}
case OpDelete:
for _, id := range e.IDs {
if b, ok := d.Blocks[id]; ok {
b.Deleted = true
}
}
case OpRestore:
for _, id := range e.IDs {
if b, ok := d.Blocks[id]; ok {
b.Deleted = false
}
}
case OpSetTitle:
d.Title, d.TitleVersion = e.Title, e.TitleVersion
}
}
// ConflictBlockID derives the ID of a conflict copy from the losing operation, so retries never duplicate it.
func ConflictBlockID(opID string) string {
sum := sha256.Sum256([]byte("conflict:" + opID))
return "blk_cf_" + hex.EncodeToString(sum[:8])
}
type applier struct {
d *Doc
a Actor
env Env
effects []Effect
}
func (ap *applier) emit(e Effect) {
e.By = ap.a.UserID
ap.d.ApplyEffect(e)
ap.effects = append(ap.effects, e)
}
func (ap *applier) foreignLease(blockID string) bool {
if ap.env.Leases == nil {
return false
}
holder, held := ap.env.Leases.HolderOf(blockID)
return held && holder != ap.a.ClientID
}
func rejected(op Op, reason string) Result {
return Result{OpID: op.OpID, Status: StatusRejected, Reason: reason}
}
// Apply validates op against d, mutates d and returns the produced effects. Rejected operations
// produce no effects; conflicting text is kept as a separate conflict block.
func Apply(d *Doc, op Op, a Actor, env Env) ([]Effect, Result) {
ap := &applier{d: d, a: a, env: env}
res := ap.apply(op)
res.OpID = op.OpID
return ap.effects, res
}
func (ap *applier) apply(op Op) Result {
d := ap.d
switch op.Kind {
case OpInsert:
if op.BlockID == "" || len(op.BlockID) > 64 {
return rejected(op, "invalid_id")
}
if _, exists := d.Blocks[op.BlockID]; exists || (ap.env.IDTaken != nil && ap.env.IDTaken(op.BlockID)) {
return rejected(op, "exists")
}
parent := ""
if op.ParentID != nil {
parent = *op.ParentID
}
if parent != "" {
if _, ok := d.Blocks[parent]; !ok {
return rejected(op, "parent_missing")
}
parent = d.liveAncestor(parent)
}
if !ValidOrderKey(op.OrderKey) {
return rejected(op, "invalid_order_key")
}
typ := op.Type
if typ == "" {
typ = "bullet"
}
if len(typ) > maxTypeLength {
return rejected(op, "invalid_type")
}
content := ""
if op.Content != nil {
content = *op.Content
}
if len(content) > maxContentLength {
return rejected(op, "too_long")
}
props := Props{}
if op.Props != nil {
props = *op.Props
}
ap.emit(Effect{Kind: OpInsert, BlockID: op.BlockID, ParentID: parent, OrderKey: op.OrderKey, Type: typ, Content: content, Props: &props, Version: 1})
return Result{Status: StatusApplied, Version: 1}
case OpSetContent:
b, ok := d.Blocks[op.BlockID]
if !ok {
return rejected(op, "missing")
}
if op.Content == nil {
return rejected(op, "no_content")
}
if len(*op.Content) > maxContentLength {
return rejected(op, "too_long")
}
if b.Deleted {
// A late edit on a deleted block brings it back instead of losing the text
ap.restore([]string{b.ID})
}
if *op.Content == b.Content {
return Result{Status: StatusApplied, Version: b.Version}
}
if ap.foreignLease(b.ID) || op.BaseVersion != b.Version {
return ap.conflictCopy(op, b, *op.Content)
}
ap.emit(Effect{Kind: OpSetContent, BlockID: b.ID, Content: *op.Content, Version: b.Version + 1})
return Result{Status: StatusApplied, Version: b.Version}
case OpSetProps:
b, ok := d.Blocks[op.BlockID]
if !ok || b.Deleted {
return rejected(op, "missing")
}
if op.Patch == nil {
return rejected(op, "no_patch")
}
typ, props := b.Type, b.Props
if p := op.Patch; p != nil {
if p.Type != nil && *p.Type != "" && len(*p.Type) <= maxTypeLength {
typ = *p.Type
}
if p.Checked != nil {
props.Checked = *p.Checked
}
if p.Collapsed != nil {
props.Collapsed = *p.Collapsed
}
if p.Lang != nil {
props.Lang = *p.Lang
}
}
if typ != b.Type || props != b.Props {
ap.emit(Effect{Kind: OpSetProps, BlockID: b.ID, Type: typ, Props: &props})
}
return Result{Status: StatusApplied}
case OpMove:
b, ok := d.Blocks[op.BlockID]
if !ok || b.Deleted {
return rejected(op, "missing")
}
if ap.foreignLease(b.ID) {
return rejected(op, "locked")
}
parent := ""
if op.ParentID != nil {
parent = *op.ParentID
}
if parent != "" {
p, ok := d.Blocks[parent]
if !ok || p.Deleted {
return rejected(op, "parent_missing")
}
if d.isAncestor(b.ID, parent) {
return rejected(op, "cycle")
}
}
if !ValidOrderKey(op.OrderKey) {
return rejected(op, "invalid_order_key")
}
if parent != b.ParentID || op.OrderKey != b.OrderKey {
ap.emit(Effect{Kind: OpMove, BlockID: b.ID, ParentID: parent, OrderKey: op.OrderKey})
}
return Result{Status: StatusApplied}
case OpDelete:
b, ok := d.Blocks[op.BlockID]
if !ok {
return rejected(op, "missing")
}
if b.Deleted {
return Result{Status: StatusApplied}
}
ids := d.subtree(b.ID, true)
for _, id := range ids {
if ap.foreignLease(id) {
return rejected(op, "locked")
}
}
ap.emit(Effect{Kind: OpDelete, IDs: ids})
return Result{Status: StatusApplied}
case OpRestore:
ids := make([]string, 0, len(op.IDs))
for _, id := range op.IDs {
if b, ok := d.Blocks[id]; ok && b.Deleted {
ids = append(ids, id)
}
}
if len(ids) > 0 {
ap.restore(ids)
}
return Result{Status: StatusApplied}
case OpSetTitle:
if op.Title == nil {
return rejected(op, "no_title")
}
if len(*op.Title) > 1000 {
return rejected(op, "too_long")
}
if *op.Title == d.Title {
return Result{Status: StatusApplied, Version: d.TitleVersion}
}
if op.BaseVersion != d.TitleVersion {
return ap.titleConflict(op, *op.Title)
}
ap.emit(Effect{Kind: OpSetTitle, Title: *op.Title, TitleVersion: d.TitleVersion + 1})
return Result{Status: StatusApplied, Version: d.TitleVersion}
}
return rejected(op, "unknown_kind")
}
// restore brings back deleted blocks; blocks whose parent stays deleted move to the nearest live ancestor.
func (ap *applier) restore(ids []string) {
d := ap.d
set := make(map[string]bool, len(ids))
for _, id := range ids {
set[id] = true
}
sorted := append([]string(nil), ids...)
sort.Strings(sorted)
ap.emit(Effect{Kind: OpRestore, IDs: sorted})
for _, id := range sorted {
b := d.Blocks[id]
if b.ParentID == "" || set[b.ParentID] {
continue
}
if p, ok := d.Blocks[b.ParentID]; ok && !p.Deleted {
continue
}
ap.emit(Effect{Kind: OpMove, BlockID: b.ID, ParentID: d.liveAncestor(b.ParentID), OrderKey: b.OrderKey})
}
}
// conflictCopy keeps the losing text as a new block directly below the contested one.
func (ap *applier) conflictCopy(op Op, b *Block, content string) Result {
id := ConflictBlockID(op.OpID)
if _, exists := ap.d.Blocks[id]; !exists {
props := Props{ConflictOf: b.ID, ConflictAuthor: ap.a.Name}
ap.emit(Effect{Kind: OpInsert, BlockID: id, ParentID: b.ParentID, OrderKey: ap.d.keyAfter(b), Type: b.Type, Content: content, Props: &props, Version: 1})
}
return Result{Status: StatusConflict, Version: b.Version, ConflictBlockID: id}
}
// titleConflict keeps a losing title as a conflict block at the very top of the note.
func (ap *applier) titleConflict(op Op, title string) Result {
id := ConflictBlockID(op.OpID)
if _, exists := ap.d.Blocks[id]; !exists {
first := ""
if roots := ap.d.Children(""); len(roots) > 0 {
first = roots[0].OrderKey
}
key, err := KeyBetween("", first)
if err != nil {
key, _ = KeyBetween("", "")
}
props := Props{ConflictOf: "title", ConflictAuthor: ap.a.Name}
ap.emit(Effect{Kind: OpInsert, BlockID: id, OrderKey: key, Type: "text", Content: title, Props: &props, Version: 1})
}
return Result{Status: StatusConflict, Version: ap.d.TitleVersion, ConflictBlockID: id}
}
+263
View File
@@ -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")
}
}
+267
View File
@@ -0,0 +1,267 @@
package collab
import (
"errors"
"strings"
)
// Order keys sort siblings lexicographically (ties broken by block ID) and allow inserting between any two
// keys without renumbering. Format as in the "fractional-indexing" scheme: an integer part whose first
// character encodes its length ('a'..'z' positive, 'A'..'Z' negative) followed by base-62 digits, then an
// optional fraction without trailing '0'. Appending stays short ("a0", "a1", …, "az", "b00", …).
const keyDigits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const smallestInteger = "A00000000000000000000000000"
var errInvalidOrderKey = errors.New("ungültiger Sortierschlüssel")
func digitIndex(c byte) int {
return strings.IndexByte(keyDigits, c)
}
func integerLength(head byte) int {
switch {
case head >= 'a' && head <= 'z':
return int(head-'a') + 2
case head >= 'A' && head <= 'Z':
return int('Z'-head) + 2
}
return 0
}
func integerPart(key string) (string, bool) {
if key == "" {
return "", false
}
n := integerLength(key[0])
if n == 0 || n > len(key) {
return "", false
}
return key[:n], true
}
// ValidOrderKey reports whether k is a well-formed key.
func ValidOrderKey(k string) bool {
if k == "" || len(k) > 256 || k == smallestInteger {
return false
}
ip, ok := integerPart(k)
if !ok {
return false
}
for i := 1; i < len(k); i++ {
if digitIndex(k[i]) < 0 {
return false
}
}
frac := k[len(ip):]
return frac == "" || frac[len(frac)-1] != '0'
}
func incrementInteger(x string) (string, bool) {
head, digs := x[0], []byte(x[1:])
carry := true
for i := len(digs) - 1; carry && i >= 0; i-- {
d := digitIndex(digs[i]) + 1
if d == len(keyDigits) {
digs[i] = '0'
} else {
digs[i] = keyDigits[d]
carry = false
}
}
if !carry {
return string(head) + string(digs), true
}
if head == 'Z' {
return "a0", true
}
if head == 'z' {
return "", false
}
h := head + 1
if h > 'a' {
digs = append(digs, '0')
} else {
digs = digs[:len(digs)-1]
}
return string(h) + string(digs), true
}
func decrementInteger(x string) (string, bool) {
head, digs := x[0], []byte(x[1:])
borrow := true
for i := len(digs) - 1; borrow && i >= 0; i-- {
d := digitIndex(digs[i]) - 1
if d == -1 {
digs[i] = 'z'
} else {
digs[i] = keyDigits[d]
borrow = false
}
}
if !borrow {
return string(head) + string(digs), true
}
if head == 'a' {
return "Z" + string(keyDigits[len(keyDigits)-1]), true
}
if head == 'A' {
return "", false
}
h := head - 1
if h < 'Z' {
digs = append(digs, 'z')
} else {
digs = digs[:len(digs)-1]
}
return string(h) + string(digs), true
}
// midpoint returns a fraction between a and b (a may be "" = 0, b may be "" = 1).
func midpoint(a, b string) string {
if b != "" {
n := 0
for n < len(b) {
ca := byte('0')
if n < len(a) {
ca = a[n]
}
if ca != b[n] {
break
}
n++
}
if n > 0 {
rest := ""
if n < len(a) {
rest = a[n:]
}
return b[:n] + midpoint(rest, b[n:])
}
}
da := 0
if a != "" {
da = digitIndex(a[0])
}
db := len(keyDigits)
if b != "" {
db = digitIndex(b[0])
}
if db-da > 1 {
return string(keyDigits[(da+db)/2])
}
if b != "" && len(b) > 1 {
return b[:1]
}
rest := ""
if len(a) > 1 {
rest = a[1:]
}
return string(keyDigits[da]) + midpoint(rest, "")
}
// KeyBetween returns a key strictly between a and b. An empty a means "before everything",
// an empty b means "after everything".
func KeyBetween(a, b string) (string, error) {
if (a != "" && !ValidOrderKey(a)) || (b != "" && !ValidOrderKey(b)) {
return "", errInvalidOrderKey
}
if a != "" && b != "" && a >= b {
return "", errInvalidOrderKey
}
switch {
case a == "" && b == "":
return "a0", nil
case a == "":
ib, _ := integerPart(b)
fb := b[len(ib):]
if ib == smallestInteger {
return ib + midpoint("", fb), nil
}
if ib < b {
return ib, nil
}
res, ok := decrementInteger(ib)
if !ok {
return "", errInvalidOrderKey
}
return res, nil
case b == "":
ia, _ := integerPart(a)
fa := a[len(ia):]
if i, ok := incrementInteger(ia); ok {
return i, nil
}
return ia + midpoint(fa, ""), nil
}
ia, _ := integerPart(a)
fa := a[len(ia):]
ib, _ := integerPart(b)
fb := b[len(ib):]
if ia == ib {
return ia + midpoint(fa, fb), nil
}
i, ok := incrementInteger(ia)
if !ok {
return "", errInvalidOrderKey
}
if i < b {
return i, nil
}
return ia + midpoint(fa, ""), nil
}
// KeysBetween returns n increasing keys between a and b, spread so that bulk inserts stay short.
func KeysBetween(a, b string, n int) ([]string, error) {
if n <= 0 {
return nil, nil
}
if n == 1 {
k, err := KeyBetween(a, b)
if err != nil {
return nil, err
}
return []string{k}, nil
}
if b == "" {
keys := make([]string, 0, n)
prev := a
for i := 0; i < n; i++ {
k, err := KeyBetween(prev, "")
if err != nil {
return nil, err
}
keys = append(keys, k)
prev = k
}
return keys, nil
}
if a == "" {
keys := make([]string, n)
next := b
for i := n - 1; i >= 0; i-- {
k, err := KeyBetween("", next)
if err != nil {
return nil, err
}
keys[i] = k
next = k
}
return keys, nil
}
mid := n / 2
c, err := KeyBetween(a, b)
if err != nil {
return nil, err
}
left, err := KeysBetween(a, c, mid)
if err != nil {
return nil, err
}
right, err := KeysBetween(c, b, n-mid-1)
if err != nil {
return nil, err
}
return append(append(left, c), right...), nil
}
+90
View File
@@ -0,0 +1,90 @@
package collab
import (
"math/rand"
"sort"
"testing"
)
func TestKeyBetweenBasics(t *testing.T) {
cases := [][2]string{{"", ""}, {"", "a0"}, {"a0", ""}, {"a0", "a1"}, {"a0", "a0V"}, {"a1", "a2"}, {"a0V", "a1"}, {"Zz", "a0"}, {"az", ""}, {"", "Zz"}}
for _, c := range cases {
k, err := KeyBetween(c[0], c[1])
if err != nil {
t.Fatalf("KeyBetween(%q,%q): %v", c[0], c[1], err)
}
if !ValidOrderKey(k) {
t.Fatalf("KeyBetween(%q,%q) = %q is not a valid key", c[0], c[1], k)
}
if (c[0] != "" && k <= c[0]) || (c[1] != "" && k >= c[1]) {
t.Fatalf("KeyBetween(%q,%q) = %q is not between", c[0], c[1], k)
}
}
if _, err := KeyBetween("a1", "a0"); err == nil {
t.Fatal("reversed range must fail")
}
if _, err := KeyBetween("a0V0", ""); err == nil {
t.Fatal("trailing zero key must be rejected")
}
}
// Random inserts between neighbours must always produce a strictly increasing sequence.
func TestKeyBetweenRandomInserts(t *testing.T) {
r := rand.New(rand.NewSource(42))
keys := []string{}
for i := 0; i < 3000; i++ {
pos := r.Intn(len(keys) + 1)
a, b := "", ""
if pos > 0 {
a = keys[pos-1]
}
if pos < len(keys) {
b = keys[pos]
}
k, err := KeyBetween(a, b)
if err != nil {
t.Fatalf("step %d: %v", i, err)
}
keys = append(keys[:pos], append([]string{k}, keys[pos:]...)...)
}
if !sort.StringsAreSorted(keys) {
t.Fatal("keys are not sorted")
}
for i := 1; i < len(keys); i++ {
if keys[i] == keys[i-1] {
t.Fatalf("duplicate key %q", keys[i])
}
}
}
// Appending at the end, the most common case, must keep keys short.
func TestKeyBetweenAppendStaysShort(t *testing.T) {
last := ""
for i := 0; i < 1000; i++ {
k, _ := KeyBetween(last, "")
last = k
}
if len(last) > 4 {
t.Fatalf("key after 1000 appends too long: %d chars", len(last))
}
}
func TestKeysBetweenBulk(t *testing.T) {
for _, r := range [][2]string{{"", ""}, {"a0", ""}, {"", "a0"}, {"a0", "a1"}} {
keys, err := KeysBetween(r[0], r[1], 500)
if err != nil {
t.Fatalf("%v: %v", r, err)
}
if len(keys) != 500 || !sort.StringsAreSorted(keys) {
t.Fatalf("%v: wrong or unsorted result", r)
}
for i, k := range keys {
if !ValidOrderKey(k) || (i > 0 && keys[i-1] == k) {
t.Fatalf("%v: bad key %q", r, k)
}
if (r[0] != "" && k <= r[0]) || (r[1] != "" && k >= r[1]) {
t.Fatalf("%v: key %q out of range", r, k)
}
}
}
}