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>
202 lines
5.3 KiB
Go
202 lines
5.3 KiB
Go
// 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
|
|
}
|