diff --git a/lib/content/crdt-tree.sx b/lib/content/crdt-tree.sx index 9ba58d49..9b76f42a 100644 --- a/lib/content/crdt-tree.sx +++ b/lib/content/crdt-tree.sx @@ -4,13 +4,14 @@ ;; (the id of its containing section, "" = root) alongside its Logoot position. ;; Merge is still a join — it reuses crdt.sx's position/register/field merges and ;; adds parent (immutable, set once at insert). Materialisation rebuilds the -;; ordered tree: root = elements with parent "", a section's children = elements -;; whose parent is that section's id, each sorted by position. Commutative, -;; associative, idempotent like the flat layer; concurrent inserts into the same -;; or different parents converge deterministically. +;; ordered tree: root = elements with parent "" (plus ORPHANS — elements whose +;; parent is not a live section, e.g. after a concurrent delete-section + +;; insert-child, so content is never silently lost); a section's children = +;; elements whose parent is that section's id. Commutative/associative/idempotent +;; like the flat layer. ;; ;; Requires (loaded by harness): crdt.sx (merge helpers + live/sort/materialise -;; bits), block.sx, doc.sx, section.sx (mk-section). +;; bits + crdt-member?), block.sx, doc.sx, section.sx (mk-section). (define ctt-merge-parent (fn (p1 p2) (if (= p1 nil) p2 p1))) @@ -129,6 +130,34 @@ (crdt-tree-apply-all (crdt-tree-apply state (first ops)) (rest ops))))) ;; ── materialise to a Phase-1 document (rebuild the ordered tree) ── +(define + ctt-live-section-ids + (fn + (state) + (map + (fn (e) (get e :id)) + (filter + (fn (e) (= (get e :type) "section")) + (crdt-live-elements state))))) + +;; an element belongs at root if its parent is "" or its parent is not a live +;; section (orphan-reparenting: don't lose content when its section is deleted). +(define + ctt-roots + (fn + (state) + (let + ((secids (ctt-live-section-ids state))) + (crdt-sort-by-pos + (filter + (fn + (e) + (if + (= (get e :parent) "") + true + (if (crdt-member? (get e :parent) secids) false true))) + (crdt-live-elements state)))))) + (define ctt-children (fn @@ -157,8 +186,8 @@ (doc-id state) (doc-new doc-id - (map (fn (e) (ctt-element->block state e)) (ctt-children state ""))))) + (map (fn (e) (ctt-element->block state e)) (ctt-roots state))))) (define crdt-tree-order - (fn (state) (map (fn (e) (get e :id)) (ctt-children state "")))) + (fn (state) (map (fn (e) (get e :id)) (ctt-roots state)))) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index b8c4625b..526cba50 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -34,7 +34,7 @@ "store": {"pass": 29, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, - "crdt-tree": {"pass": 17, "fail": 0}, + "crdt-tree": {"pass": 21, "fail": 0}, "crdt-blocks": {"pass": 7, "fail": 0}, "crdt-store": {"pass": 14, "fail": 0}, "sync": {"pass": 14, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 738, + "total_pass": 742, "total_fail": 0, - "total": 738 + "total": 742 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 9cdf12c2..0811d718 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -38,11 +38,11 @@ _Generated by `lib/content/conformance.sh`_ | store | 29 | 0 | 29 | | snapshot | 20 | 0 | 20 | | crdt | 34 | 0 | 34 | -| crdt-tree | 17 | 0 | 17 | +| crdt-tree | 21 | 0 | 21 | | crdt-blocks | 7 | 0 | 7 | | crdt-store | 14 | 0 | 14 | | sync | 14 | 0 | 14 | | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **738** | **0** | **738** | +| **Total** | **742** | **0** | **742** | diff --git a/lib/content/tests/crdt-tree.sx b/lib/content/tests/crdt-tree.sx index b70c6f95..677a3593 100644 --- a/lib/content/tests/crdt-tree.sx +++ b/lib/content/tests/crdt-tree.sx @@ -251,3 +251,39 @@ "two-replica tree ids" (doc-tree-ids (crdt-tree-materialize "d" (crdt-tree-merge repl1 repl2))) (list "s1" "p1" "s2" "p2")) + +;; ── orphan reparenting: concurrent delete-section + insert-child ── +;; A deletes section s; B inserts a child into s. After merge, s is gone but the +;; child must survive (reparented to root), not silently vanish. +(define delA (crdt-tree-delete base "s")) +(define + insB + (crdt-tree-insert + base + "c" + "text" + (crdt-pos 9 0) + "s" + (list (list "text" "kept")) + 5 + 2)) +(define orphan-merge (crdt-tree-merge delA insB)) +(content-test + "orphan survives delete-section" + (doc-tree-ids (crdt-tree-materialize "d" orphan-merge)) + (list "h" "c")) +(content-test + "orphan reparent commutes" + (same? orphan-merge (crdt-tree-merge insB delA)) + true) +(content-test + "orphan content preserved" + (str + (blk-send + (doc-deep-find (crdt-tree-materialize "d" orphan-merge) "c") + "text")) + "kept") +(content-test + "orphan render at root" + (asHTML (crdt-tree-materialize "d" orphan-merge)) + "

Sub

kept

") diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 69d5cb74..f4c920c9 100644 --- a/plans/content-on-sx.md +++ b/plans/content-on-sx.md @@ -19,7 +19,7 @@ injected adapter, not core. ## Status (rolling) -`bash lib/content/conformance.sh` → **738/738** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) +`bash lib/content/conformance.sh` → **742/742** (Phases 1–4 COMPLETE + ~34 extensions, hardened: HTML/SX escaping, Markdown render + import/export incl. tables & frontmatter (full round-trip), CvRDT flat + nested-tree + durable replication, tree-aware validation, snapshot cache, doc metadata, plain-text render, nested block trees + deep editing + flatten + relative reorder, doc stats + summary + multi-doc index, table + callout + media blocks, HTML page wrapper + SEO page, doc composition + id-remap, portable data + wire serialization, block query + transforms + find/replace, TOC + anchored headings + outline, normalization) ## Ground rules @@ -113,6 +113,13 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Hardening: tree-CRDT orphan reparenting. Concurrent + delete-section + insert-child previously orphaned the child (its parent no + longer a live section) → silently dropped from materialise. Fixed + `crdt-tree-materialize`/`crdt-tree-order` to root any element whose parent is + "" OR not a live section, so content is never lost on concurrent edits. +4 + tests (orphan survives, commutes, content preserved, renders at root). Suite + 742/742. - 2026-06-07 — Hardening: regression suite `crdt-blocks` (7 tests) locking that non-core block types (callout/table/media/section) survive both the flat and nested-tree CvRDT materialise paths (insert → merge → materialise → render),