diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index de8ba21a..8f43b843 100755 --- a/lib/content/conformance.sh +++ b/lib/content/conformance.sh @@ -15,7 +15,7 @@ if [ ! -x "$SX_SERVER" ]; then fi fi -SUITES=(block doc render api) +SUITES=(block doc render api store) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -33,10 +33,16 @@ run_suite() { (load "lib/smalltalk/runtime.sx") (load "lib/guest/reflective/env.sx") (load "lib/smalltalk/eval.sx") +(load "lib/persist/event.sx") +(load "lib/persist/backend.sx") +(load "lib/persist/log.sx") +(load "lib/persist/kv.sx") +(load "lib/persist/api.sx") (load "lib/content/block.sx") (load "lib/content/doc.sx") (load "lib/content/render.sx") (load "lib/content/api.sx") +(load "lib/content/store.sx") (epoch 2) (eval "(define content-test-pass 0)") (eval "(define content-test-fail 0)") diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index b9270ccf..25263cb1 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -3,9 +3,10 @@ "block": {"pass": 38, "fail": 0}, "doc": {"pass": 40, "fail": 0}, "render": {"pass": 29, "fail": 0}, - "api": {"pass": 26, "fail": 0} + "api": {"pass": 26, "fail": 0}, + "store": {"pass": 29, "fail": 0} }, - "total_pass": 133, + "total_pass": 162, "total_fail": 0, - "total": 133 + "total": 162 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index 526ac685..6a922a4b 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -8,4 +8,5 @@ _Generated by `lib/content/conformance.sh`_ | doc | 40 | 0 | 40 | | render | 29 | 0 | 29 | | api | 26 | 0 | 26 | -| **Total** | **133** | **0** | **133** | +| store | 29 | 0 | 29 | +| **Total** | **162** | **0** | **162** | diff --git a/lib/content/store.sx b/lib/content/store.sx new file mode 100644 index 00000000..ef840c13 --- /dev/null +++ b/lib/content/store.sx @@ -0,0 +1,101 @@ +;; content-on-sx — op log + versioning over the persist event stream. +;; +;; The op log is the source of truth. Editing a document = appending the edit op +;; as a persist event to the document's stream. Any version of the document is a +;; replay of its op stream up to a sequence number; the materialised doc is a +;; cache, never primary state. +;; +;; Requires (loaded by the harness): block.sx, doc.sx, and persist +;; (event/backend/log/kv/api). The persist backend `b` is opened by the caller +;; via (persist/open) and injected — content knows nothing about which backend. + +(define content/-stream (fn (doc-id) (str "content:" doc-id))) + +;; ── commit: append an edit op as an event. `at` is a caller-supplied logical +;; timestamp (Date.now is unavailable in-kernel). Returns the stored event. ── +(define + content/commit! + (fn + (b doc-id op at) + (persist/append b (content/-stream doc-id) (get op :op) at op))) + +(define + content/commit-all! + (fn + (b doc-id ops at) + (if + (= (len ops) 0) + nil + (begin + (content/commit! b doc-id (first ops) at) + (content/commit-all! b doc-id (rest ops) at))))) + +;; ── read the raw log / op stream ── +(define + content/log + (fn (b doc-id) (persist/read b (content/-stream doc-id)))) + +(define + content/ops + (fn + (b doc-id) + (map (fn (ev) (persist/event-data ev)) (content/log b doc-id)))) + +;; logical version count (highest seq assigned, survives compaction) +(define + content/version-count + (fn (b doc-id) (persist/last-seq b (content/-stream doc-id)))) + +;; ── replay ── +;; head — materialise the latest document by folding all ops. +(define + content/head + (fn (b doc-id) (doc-apply-all (doc-empty doc-id) (content/ops b doc-id)))) + +;; at — materialise the document as of sequence `seq` (a version). +(define + content/at + (fn + (b doc-id seq) + (let + ((evs (filter (fn (ev) (<= (persist/event-seq ev) seq)) (content/log b doc-id)))) + (doc-apply-all + (doc-empty doc-id) + (map (fn (ev) (persist/event-data ev)) evs))))) + +;; ── history: per-version metadata, oldest-first ── +(define + content/history + (fn (b doc-id) (map (fn (ev) {:type (persist/event-type ev) :at (persist/event-at ev) :seq (persist/event-seq ev)}) (content/log b doc-id)))) + +;; ── diff between two materialised document versions ── +;; Returns {:added (ids) :removed (ids) :changed (ids)} where changed = ids +;; present in both whose block content differs. +(define + content/-missing? + (fn (doc id) (= (ct-index-of (doc-blocks doc) id) -1))) + +(define + content/-changed + (fn + (old new) + (filter + (fn + (id) + (let + ((bo (doc-find old id)) (bn (doc-find new id))) + (cond + ((= bo nil) false) + ((= bn nil) false) + ((= bo bn) false) + (else true)))) + (doc-ids old)))) + +(define content/diff (fn (old new) {:changed (content/-changed old new) :removed (filter (fn (id) (content/-missing? new id)) (doc-ids old)) :added (filter (fn (id) (content/-missing? old id)) (doc-ids new))})) + +;; convenience: diff two persisted versions by seq. +(define + content/diff-versions + (fn + (b doc-id seq-a seq-b) + (content/diff (content/at b doc-id seq-a) (content/at b doc-id seq-b)))) diff --git a/lib/content/tests/store.sx b/lib/content/tests/store.sx new file mode 100644 index 00000000..6065bc5e --- /dev/null +++ b/lib/content/tests/store.sx @@ -0,0 +1,121 @@ +;; Phase 2 — op log + versioning over persist. The log is the source of truth; +;; any version is a replay of the op stream up to a seq. + +(st-bootstrap-classes!) +(content-bootstrap-blocks!) +(content-bootstrap-doc!) + +(define B (persist/open)) +(define h (mk-heading "h" 1 "Title")) +(define p (mk-text "p" "Body")) +(define img (mk-image "img" "/c.png" "cat")) + +;; ── commit an op stream ── +(content/commit! B "post" (op-insert h nil) 10) +(content/commit! B "post" (op-insert p "h") 11) +(content/commit! B "post" (op-insert img "h") 12) +(content/commit! B "post" (op-update "p" "text" "Edited") 13) +(content/commit! B "post" (op-delete "img") 14) + +(content-test "version-count" (content/version-count B "post") 5) +(content-test "log length" (len (content/log B "post")) 5) + +;; ── head: latest materialised document ── +(content-test "head ids" (doc-ids (content/head B "post")) (list "h" "p")) +(content-test + "head p edited" + (str (blk-send (doc-find (content/head B "post") "p") "text")) + "Edited") + +;; ── replay to any version ── +(content-test + "at seq1" + (doc-ids (content/at B "post" 1)) + (list "h")) +(content-test + "at seq2" + (doc-ids (content/at B "post" 2)) + (list "h" "p")) +(content-test + "at seq3" + (doc-ids (content/at B "post" 3)) + (list "h" "img" "p")) +(content-test + "at seq3 p original" + (str (blk-send (doc-find (content/at B "post" 3) "p") "text")) + "Body") +(content-test + "at seq4 p edited" + (str (blk-send (doc-find (content/at B "post" 4) "p") "text")) + "Edited") +(content-test + "at seq5 img gone" + (doc-ids (content/at B "post" 5)) + (list "h" "p")) +(content-test + "at seq0 empty" + (doc-ids (content/at B "post" 0)) + (list)) + +;; ── ops accessor ── +(content-test + "ops kinds" + (map (fn (o) (get o :op)) (content/ops B "post")) + (list "insert" "insert" "insert" "update" "delete")) + +;; ── history metadata ── +(define hist (content/history B "post")) +(content-test "history length" (len hist) 5) +(content-test "history first seq" (get (first hist) :seq) 1) +(content-test "history first type" (get (first hist) :type) "insert") +(content-test "history first at" (get (first hist) :at) 10) +(content-test + "history fourth type" + (get (nth hist 3) :type) + "update") + +;; ── diff between versions ── +(define dvf (content/diff-versions B "post" 1 3)) +(content-test "diff added" (get dvf :added) (list "img" "p")) +(content-test "diff removed empty" (get dvf :removed) (list)) +(content-test "diff changed empty" (get dvf :changed) (list)) + +(define dvf2 (content/diff-versions B "post" 3 5)) +(content-test "diff2 removed" (get dvf2 :removed) (list "img")) +(content-test "diff2 changed" (get dvf2 :changed) (list "p")) +(content-test "diff2 added empty" (get dvf2 :added) (list)) + +;; ── direct diff of two materialised docs ── +(define da (content/at B "post" 2)) +(define db (content/at B "post" 5)) +(content-test + "direct diff changed" + (get (content/diff da db) :changed) + (list "p")) +(content-test + "direct diff no-op" + (get (content/diff da da) :changed) + (list)) + +;; ── commit-all batch ── +(define B2 (persist/open)) +(content/commit-all! + B2 + "doc2" + (list (op-insert h nil) (op-insert p "h")) + 1) +(content-test "commit-all count" (content/version-count B2 "doc2") 2) +(content-test + "commit-all head" + (doc-ids (content/head B2 "doc2")) + (list "h" "p")) + +;; ── stream isolation ── +(content-test + "separate stream empty" + (content/version-count B "doc2") + 0) +(content-test + "head of empty stream" + (doc-ids (content/head B "never")) + (list)) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index 7917f442..8a9782d8 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` → **133/133** (Phase 1 complete: blocks + doc + render + api) +`bash lib/content/conformance.sh` → **162/162** (Phase 1 complete + Phase 2: persist op log) ## Ground rules @@ -63,8 +63,8 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] `api.sx` + tests + scoreboard + conformance.sh ## Phase 2 — Op log + versioning -- [ ] edit ops as `persist` events; replay to any version -- [ ] `(content/history doc)`, diff between versions +- [x] edit ops as `persist` events; replay to any version +- [x] `(content/history doc)`, diff between versions ## Phase 3 — Collaborative merge (CRDT) - [ ] commutative/idempotent op merge @@ -77,6 +77,15 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ ## Progress log +- 2026-06-07 — Phase 2 `store.sx` (**Phase 2 complete**): op log + versioning + over the persist event stream. `content/commit!` appends an edit op as a + persist event to the doc's stream (`content:`); the log is the source of + truth. `content/head` / `content/at b id seq` replay the op stream to the + latest / any version (materialised doc is a cache, never primary state). + `content/history` returns per-version metadata; `content/diff` / + `content/diff-versions` report added/removed/changed block ids. Backend is + injected via `(persist/open)` — content knows nothing about which backend. + Minimal persist load (event/backend/log/kv/api). 29 tests; suite 162/162. - 2026-06-07 — Phase 1 `api.sx` (**Phase 1 complete**): `content/*` facade over block + doc + render. `content/bootstrap!` registers the hierarchy; `content/edit` applies one op or an op stream; `content/render` picks the