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

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