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>
91 lines
2.3 KiB
Go
91 lines
2.3 KiB
Go
package collab
|
|
|
|
import (
|
|
"math/rand"
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
func TestKeyBetweenBasics(t *testing.T) {
|
|
cases := [][2]string{{"", ""}, {"", "a0"}, {"a0", ""}, {"a0", "a1"}, {"a0", "a0V"}, {"a1", "a2"}, {"a0V", "a1"}, {"Zz", "a0"}, {"az", ""}, {"", "Zz"}}
|
|
for _, c := range cases {
|
|
k, err := KeyBetween(c[0], c[1])
|
|
if err != nil {
|
|
t.Fatalf("KeyBetween(%q,%q): %v", c[0], c[1], err)
|
|
}
|
|
if !ValidOrderKey(k) {
|
|
t.Fatalf("KeyBetween(%q,%q) = %q is not a valid key", c[0], c[1], k)
|
|
}
|
|
if (c[0] != "" && k <= c[0]) || (c[1] != "" && k >= c[1]) {
|
|
t.Fatalf("KeyBetween(%q,%q) = %q is not between", c[0], c[1], k)
|
|
}
|
|
}
|
|
if _, err := KeyBetween("a1", "a0"); err == nil {
|
|
t.Fatal("reversed range must fail")
|
|
}
|
|
if _, err := KeyBetween("a0V0", ""); err == nil {
|
|
t.Fatal("trailing zero key must be rejected")
|
|
}
|
|
}
|
|
|
|
// Random inserts between neighbours must always produce a strictly increasing sequence.
|
|
func TestKeyBetweenRandomInserts(t *testing.T) {
|
|
r := rand.New(rand.NewSource(42))
|
|
keys := []string{}
|
|
for i := 0; i < 3000; i++ {
|
|
pos := r.Intn(len(keys) + 1)
|
|
a, b := "", ""
|
|
if pos > 0 {
|
|
a = keys[pos-1]
|
|
}
|
|
if pos < len(keys) {
|
|
b = keys[pos]
|
|
}
|
|
k, err := KeyBetween(a, b)
|
|
if err != nil {
|
|
t.Fatalf("step %d: %v", i, err)
|
|
}
|
|
keys = append(keys[:pos], append([]string{k}, keys[pos:]...)...)
|
|
}
|
|
if !sort.StringsAreSorted(keys) {
|
|
t.Fatal("keys are not sorted")
|
|
}
|
|
for i := 1; i < len(keys); i++ {
|
|
if keys[i] == keys[i-1] {
|
|
t.Fatalf("duplicate key %q", keys[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Appending at the end, the most common case, must keep keys short.
|
|
func TestKeyBetweenAppendStaysShort(t *testing.T) {
|
|
last := ""
|
|
for i := 0; i < 1000; i++ {
|
|
k, _ := KeyBetween(last, "")
|
|
last = k
|
|
}
|
|
if len(last) > 4 {
|
|
t.Fatalf("key after 1000 appends too long: %d chars", len(last))
|
|
}
|
|
}
|
|
|
|
func TestKeysBetweenBulk(t *testing.T) {
|
|
for _, r := range [][2]string{{"", ""}, {"a0", ""}, {"", "a0"}, {"a0", "a1"}} {
|
|
keys, err := KeysBetween(r[0], r[1], 500)
|
|
if err != nil {
|
|
t.Fatalf("%v: %v", r, err)
|
|
}
|
|
if len(keys) != 500 || !sort.StringsAreSorted(keys) {
|
|
t.Fatalf("%v: wrong or unsorted result", r)
|
|
}
|
|
for i, k := range keys {
|
|
if !ValidOrderKey(k) || (i > 0 && keys[i-1] == k) {
|
|
t.Fatalf("%v: bad key %q", r, k)
|
|
}
|
|
if (r[0] != "" && k <= r[0]) || (r[1] != "" && k >= r[1]) {
|
|
t.Fatalf("%v: key %q out of range", r, k)
|
|
}
|
|
}
|
|
}
|
|
}
|