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>
268 lines
5.4 KiB
Go
268 lines
5.4 KiB
Go
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
|
|
}
|