MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s
- Background job at start and every 6 h: log entries older than 30 days (private notes: 7) are removed, documents.log_floor marks the boundary; clients behind it get resync and keep their queued changes - Deleted blocks are purged 30 days after deleted_at via a "purge" log effect, so open editors drop them without reloading; a tombstone stays while a deleted child still points to it - Text for a purged block becomes a new block at the end; inserts under a purged parent go to the top level (server and browser model) - doc_op_results kept for 90 days; expired lease/session maps are swept - Tests: storage, API, engine resync, simulation with compactions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
464 lines
14 KiB
Go
464 lines
14 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"
|
|
// OpPurge is only produced by log compaction: deleted blocks older than the retention are removed for good
|
|
OpPurge = "purge"
|
|
|
|
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"`
|
|
// ClearConflict removes the conflict marking (after the user accepted or kept a conflict copy)
|
|
ClearConflict *bool `json:"clearConflict,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
|
|
case OpPurge:
|
|
for _, id := range e.IDs {
|
|
delete(d.Blocks, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
parent = d.liveAncestor(parent)
|
|
} else {
|
|
parent = "" // parent was purged after the retention period: keep the block at the top level
|
|
}
|
|
}
|
|
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 ap.reviveAsNewBlock(op)
|
|
}
|
|
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 p.ClearConflict != nil && *p.ClearConflict {
|
|
props.ConflictOf, props.ConflictAuthor = "", ""
|
|
}
|
|
}
|
|
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})
|
|
}
|
|
}
|
|
|
|
// reviveAsNewBlock handles text for a block that no longer exists (its tombstone was purged after the
|
|
// retention period, e.g. a device was offline for weeks): the text becomes a new block at the end.
|
|
func (ap *applier) reviveAsNewBlock(op Op) Result {
|
|
if op.Content == nil || *op.Content == "" || op.BlockID == "" || len(op.BlockID) > 64 {
|
|
return rejected(op, "missing")
|
|
}
|
|
if ap.env.IDTaken != nil && ap.env.IDTaken(op.BlockID) {
|
|
return rejected(op, "missing")
|
|
}
|
|
if len(*op.Content) > maxContentLength {
|
|
return rejected(op, "too_long")
|
|
}
|
|
last := ""
|
|
if roots := ap.d.Children(""); len(roots) > 0 {
|
|
last = roots[len(roots)-1].OrderKey
|
|
}
|
|
key, err := KeyBetween(last, "")
|
|
if err != nil {
|
|
return rejected(op, "missing")
|
|
}
|
|
ap.emit(Effect{Kind: OpInsert, BlockID: op.BlockID, OrderKey: key, Type: "bullet", Content: *op.Content, Props: &Props{}, Version: 1})
|
|
return Result{Status: StatusApplied, Version: 1, Reason: "revived"}
|
|
}
|
|
|
|
// Purge removes tombstones for good. Only blocks whose whole deleted subtree is purged too may go, so no
|
|
// remaining block refers to a missing parent. Returns the effect (nil if nothing to purge).
|
|
func Purge(d *Doc, candidates []string) *Effect {
|
|
set := make(map[string]bool, len(candidates))
|
|
for _, id := range candidates {
|
|
if b, ok := d.Blocks[id]; ok && b.Deleted {
|
|
set[id] = true
|
|
}
|
|
}
|
|
for changed := true; changed; {
|
|
changed = false
|
|
for _, b := range d.Blocks {
|
|
if !set[b.ID] && set[b.ParentID] {
|
|
delete(set, b.ParentID) // a remaining block still points to it
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if len(set) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(set))
|
|
for id := range set {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
e := Effect{Kind: OpPurge, IDs: ids}
|
|
d.ApplyEffect(e)
|
|
return &e
|
|
}
|
|
|
|
// 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}
|
|
}
|