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
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:
1 parent
bcc06da3ee
commit
1ea0c78b41
23 files changed
+3266
-287
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user