Files
MindSift/internal/collab/ops.go
T
sandrodachandClaude Opus 5.5 1ea0c78b41
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
feat(notes): operation log, block leases and sync API for collaborative editing
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>
2026-10-28 11:47:56 +01:00

397 lines
11 KiB
Go

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}
}