From 94b889c911307e47a1f00073dd8e1888fd15b83d Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 10:25:54 +0000 Subject: [PATCH] =?UTF-8?q?content:=20by-id=20ops=20(update/delete)=20act?= =?UTF-8?q?=20tree-wide=20=E2=80=94=20fixes=20op-log=20no-op=20on=20nested?= =?UTF-8?q?=20blocks=20+=204=20tests=20(750/750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/content/doc.sx | 43 +++++++++++++++++++++++++++++-------- lib/content/scoreboard.json | 6 +++--- lib/content/scoreboard.md | 4 ++-- lib/content/tests/store.sx | 27 +++++++++++++++++++++++ plans/content-on-sx.md | 10 ++++++++- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/lib/content/doc.sx b/lib/content/doc.sx index 5675d35b..a12c45fe 100644 --- a/lib/content/doc.sx +++ b/lib/content/doc.sx @@ -5,14 +5,18 @@ ;; and returns a NEW document — the input is never mutated, so any version is the ;; head of an op stream (replay-friendly for persist + CRDT merge). ;; -;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx for the -;; ergonomic API; they default nil and do not affect block operations. +;; By-id ops (update/delete) are TREE-WIDE: they descend into any block carrying +;; a `children` list (i.e. sections), since ids are unique across the tree. This +;; keeps the persist op-log and content/edit correct for nested documents. +;; insert/move are positional and act at the top level. +;; +;; CtDoc also carries optional metadata (title/slug/tags) — see meta.sx. ;; ;; Op shapes (data, not objects — they are the persist event payload): -;; {:op "insert" :block :after } ; after nil = prepend -;; {:op "update" :id :field :value } -;; {:op "move" :id :index } -;; {:op "delete" :id } +;; {:op "insert" :block :after } ; after nil = prepend (top level) +;; {:op "update" :id :field :value } ; tree-wide by id +;; {:op "move" :id :index } ; top level +;; {:op "delete" :id } ; tree-wide by id (define content-bootstrap-doc! @@ -76,17 +80,38 @@ (first blocks) (ct-insert-at (rest blocks) (- i 1) x)))))) +;; tree-wide remove by id: drop matches at this level, recurse into children +;; (blocks carrying a `children` list, i.e. sections). (define ct-remove-id (fn (blocks id) - (filter (fn (b) (if (= (blk-id b) id) false true)) blocks))) + (map + (fn + (b) + (let + ((ch (st-iv-get b "children"))) + (if (list? ch) (st-iv-set! b "children" (ct-remove-id ch id)) b))) + (filter (fn (b) (if (= (blk-id b) id) false true)) blocks)))) +;; tree-wide replace by id: apply f to the match wherever it sits in the tree. (define ct-replace-id (fn (blocks id f) - (map (fn (b) (if (= (blk-id b) id) (f b) b)) blocks))) + (map + (fn + (b) + (if + (= (blk-id b) id) + (f b) + (let + ((ch (st-iv-get b "children"))) + (if + (list? ch) + (st-iv-set! b "children" (ct-replace-id ch id f)) + b)))) + blocks))) ;; ── query ── (define doc-index-of (fn (doc id) (ct-index-of (doc-blocks doc) id))) @@ -164,7 +189,7 @@ ;; ── op constructors (data payload, reused by persist op log) ── (define op-insert (fn (block after) {:after after :op "insert" :block block})) -(define op-update (fn (id field value) {:field field :id id :op "update" :value value})) +(define op-update (fn (id field value) {:id id :field field :op "update" :value value})) (define op-move (fn (id index) {:id id :op "move" :index index})) diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index f5634352..4531cd82 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -31,7 +31,7 @@ "data": {"pass": 25, "fail": 0}, "wire": {"pass": 11, "fail": 0}, "validate": {"pass": 23, "fail": 0}, - "store": {"pass": 33, "fail": 0}, + "store": {"pass": 37, "fail": 0}, "snapshot": {"pass": 20, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, "crdt-tree": {"pass": 21, "fail": 0}, @@ -42,7 +42,7 @@ "md-doc": {"pass": 12, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 746, + "total_pass": 750, "total_fail": 0, - "total": 746 + "total": 750 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 73064634..15c1ba85 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -35,7 +35,7 @@ _Generated by `lib/content/conformance.sh`_ | data | 25 | 0 | 25 | | wire | 11 | 0 | 11 | | validate | 23 | 0 | 23 | -| store | 33 | 0 | 33 | +| store | 37 | 0 | 37 | | snapshot | 20 | 0 | 20 | | crdt | 34 | 0 | 34 | | crdt-tree | 21 | 0 | 21 | @@ -45,4 +45,4 @@ _Generated by `lib/content/conformance.sh`_ | md-import | 38 | 0 | 38 | | md-doc | 12 | 0 | 12 | | fed | 20 | 0 | 20 | -| **Total** | **746** | **0** | **746** | +| **Total** | **750** | **0** | **750** | diff --git a/lib/content/tests/store.sx b/lib/content/tests/store.sx index 56e0061a..f452b66d 100644 --- a/lib/content/tests/store.sx +++ b/lib/content/tests/store.sx @@ -151,3 +151,30 @@ "op-log media type" (blk-type (doc-find (content/head B3 "rich") "v")) "media") + +;; ── op-log update/delete reach NESTED blocks (tree-wide by id) ── +(content-bootstrap-section!) +(define B4 (persist/open)) +(content/commit! + B4 + "nest" + (op-insert (mk-section "sec" (list (mk-text "n" "orig"))) nil) + 1) +(content/commit! B4 "nest" (op-update "n" "text" "edited") 2) +(content-test + "op-log nested update" + (str (blk-send (doc-deep-find (content/head B4 "nest") "n") "text")) + "edited") +(content-test + "op-log nested update tree intact" + (doc-tree-ids (content/head B4 "nest")) + (list "sec" "n")) +(content/commit! B4 "nest" (op-delete "n") 3) +(content-test + "op-log nested delete" + (doc-tree-ids (content/head B4 "nest")) + (list "sec")) +(content-test + "op-log nested delete via content/at seq2" + (doc-tree-ids (content/at B4 "nest" 2)) + (list "sec" "n")) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index c25bdc7c..6187f5fd 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` → **746/746** (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` → **750/750** (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,14 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Hardening: fixed a real layer seam (surfaced in the architecture + review) — by-id ops (update/delete) now act TREE-WIDE. `ct-replace-id` / + `ct-remove-id` (doc.sx) descend into any block carrying a `children` list, so + the persist op-log and `content/edit` correctly reach blocks nested in + sections (previously a silent no-op). `doc-move` stays top-level (guarded by + doc-find); insert/move remain positional. Inline section detection (no + section.sx dep). +4 store regression tests (nested update/delete via op-log + + replay-to-seq). Full gate over foundational doc.sx: 750/750. - 2026-06-07 — Hardening: audit confirmed the persist op-log (store.sx) carries every block type through commit → replay (op-insert carries the block instance; updates apply by id). Locked with +4 store tests (callout/media