diff --git a/lib/content/conformance.sh b/lib/content/conformance.sh index 8084c116..f529800a 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 markdown validate store crdt crdt-store sync md-import fed) +SUITES=(block doc render api markdown validate store snapshot crdt crdt-store sync md-import fed) OUT_JSON="lib/content/scoreboard.json" OUT_MD="lib/content/scoreboard.md" @@ -45,6 +45,7 @@ run_suite() { (load "lib/content/markdown.sx") (load "lib/content/validate.sx") (load "lib/content/store.sx") +(load "lib/content/snapshot.sx") (load "lib/content/crdt.sx") (load "lib/content/crdt-store.sx") (load "lib/content/sync.sx") diff --git a/lib/content/scoreboard.json b/lib/content/scoreboard.json index 27c95023..c1af9deb 100644 --- a/lib/content/scoreboard.json +++ b/lib/content/scoreboard.json @@ -7,13 +7,14 @@ "markdown": {"pass": 20, "fail": 0}, "validate": {"pass": 17, "fail": 0}, "store": {"pass": 29, "fail": 0}, + "snapshot": {"pass": 20, "fail": 0}, "crdt": {"pass": 34, "fail": 0}, "crdt-store": {"pass": 14, "fail": 0}, "sync": {"pass": 14, "fail": 0}, "md-import": {"pass": 24, "fail": 0}, "fed": {"pass": 20, "fail": 0} }, - "total_pass": 318, + "total_pass": 338, "total_fail": 0, - "total": 318 + "total": 338 } diff --git a/lib/content/scoreboard.md b/lib/content/scoreboard.md index cdc9f735..e87aa5e0 100644 --- a/lib/content/scoreboard.md +++ b/lib/content/scoreboard.md @@ -11,9 +11,10 @@ _Generated by `lib/content/conformance.sh`_ | markdown | 20 | 0 | 20 | | validate | 17 | 0 | 17 | | store | 29 | 0 | 29 | +| snapshot | 20 | 0 | 20 | | crdt | 34 | 0 | 34 | | crdt-store | 14 | 0 | 14 | | sync | 14 | 0 | 14 | | md-import | 24 | 0 | 24 | | fed | 20 | 0 | 20 | -| **Total** | **318** | **0** | **318** | +| **Total** | **338** | **0** | **338** | diff --git a/lib/content/snapshot.sx b/lib/content/snapshot.sx new file mode 100644 index 00000000..1601ec6e --- /dev/null +++ b/lib/content/snapshot.sx @@ -0,0 +1,90 @@ +;; content-on-sx — snapshot cache over the op-log replay. +;; +;; Snapshots are a CACHE, never primary state: the op log stays the source of +;; truth. A snapshot stores a materialised document at a sequence in the persist +;; KV; cached reads start from it and replay only the tail of ops, so they return +;; a document IDENTICAL to a full replay — just faster. Drop the snapshot and +;; nothing is lost. +;; +;; Requires (loaded by harness): store.sx (+ doc.sx, persist event/log/kv/api). + +(define content/-snap-key (fn (doc-id) (str "content-snap:" doc-id))) + +;; take a snapshot of the current head at the current version. Returns the seq. +(define + content/snapshot! + (fn + (b doc-id) + (let + ((seq (content/version-count b doc-id))) + (begin (persist/kv-put b (content/-snap-key doc-id) {:doc (content/head b doc-id) :seq seq}) seq)))) + +(define + content/-snapshot + (fn + (b doc-id) + (if + (persist/kv-has? b (content/-snap-key doc-id)) + (persist/kv-get b (content/-snap-key doc-id)) + nil))) + +(define + content/snapshot-seq + (fn + (b doc-id) + (let + ((s (content/-snapshot b doc-id))) + (if (= s nil) 0 (get s :seq))))) + +(define + content/has-snapshot? + (fn (b doc-id) (persist/kv-has? b (content/-snap-key doc-id)))) + +(define + content/drop-snapshot! + (fn (b doc-id) (persist/kv-delete b (content/-snap-key doc-id)))) + +;; ── cached reads (transparent: identical result to store.sx replay) ── +(define + content/-tail-ops + (fn + (b doc-id from to) + (map + (fn (ev) (persist/event-data ev)) + (filter + (fn + (ev) + (and + (> (persist/event-seq ev) from) + (<= (persist/event-seq ev) to))) + (content/log b doc-id))))) + +(define + content/head-cached + (fn + (b doc-id) + (let + ((snap (content/-snapshot b doc-id))) + (if + (= snap nil) + (content/head b doc-id) + (doc-apply-all + (get snap :doc) + (content/-tail-ops + b + doc-id + (get snap :seq) + (content/version-count b doc-id))))))) + +(define + content/at-cached + (fn + (b doc-id seq) + (let + ((snap (content/-snapshot b doc-id))) + (if + (or (= snap nil) (< seq (get snap :seq))) + (content/at b doc-id seq) + (doc-apply-all + (get snap :doc) + (content/-tail-ops b doc-id (get snap :seq) seq)))))) diff --git a/lib/content/tests/snapshot.sx b/lib/content/tests/snapshot.sx new file mode 100644 index 00000000..a4f2cec4 --- /dev/null +++ b/lib/content/tests/snapshot.sx @@ -0,0 +1,100 @@ +;; Extension — snapshot cache over op-log replay. The cache is transparent: +;; cached reads equal full replays. + +(st-bootstrap-classes!) +(content-bootstrap-blocks!) +(content-bootstrap-doc!) + +(define B (persist/open)) +(define h (mk-heading "h" 1 "T")) +(define p (mk-text "p" "Body")) +(define img (mk-image "img" "/c.png" "cat")) + +(content/commit! B "post" (op-insert h nil) 1) +(content/commit! B "post" (op-insert p "h") 2) +(content/commit! B "post" (op-insert img "h") 3) +(content/commit! B "post" (op-update "p" "text" "Edited") 4) + +;; ── no snapshot yet: cached == full replay ── +(content-test + "no snapshot head-cached == head" + (doc-ids (content/head-cached B "post")) + (doc-ids (content/head B "post"))) +(content-test + "has-snapshot? false initially" + (content/has-snapshot? B "post") + false) +(content-test + "snapshot-seq 0 initially" + (content/snapshot-seq B "post") + 0) + +;; ── take a snapshot at seq 4 ── +(content-test "snapshot returns seq" (content/snapshot! B "post") 4) +(content-test "has-snapshot? true" (content/has-snapshot? B "post") true) +(content-test "snapshot-seq is 4" (content/snapshot-seq B "post") 4) + +;; cached head equals full head right after snapshot +(content-test + "head-cached == head after snap" + (doc-ids (content/head-cached B "post")) + (list "h" "img" "p")) +(content-test + "head-cached p value" + (str (blk-send (doc-find (content/head-cached B "post") "p") "text")) + "Edited") + +;; ── commit more after the snapshot; cached head replays only the tail ── +(content/commit! B "post" (op-delete "img") 5) +(content/commit! B "post" (op-insert (mk-text "q" "New") "p") 6) +(content-test + "head-cached reflects post-snapshot ops" + (doc-ids (content/head-cached B "post")) + (doc-ids (content/head B "post"))) +(content-test + "head-cached order" + (doc-ids (content/head-cached B "post")) + (list "h" "p" "q")) + +;; ── at-cached transparency across versions ── +(content-test + "at-cached seq2 (before snap) == at" + (doc-ids (content/at-cached B "post" 2)) + (doc-ids (content/at B "post" 2))) +(content-test + "at-cached seq5 (after snap) == at" + (doc-ids (content/at-cached B "post" 5)) + (doc-ids (content/at B "post" 5))) +(content-test + "at-cached seq6 == at" + (doc-ids (content/at-cached B "post" 6)) + (doc-ids (content/at B "post" 6))) +(content-test + "at-cached seq4 == snapshot version" + (doc-ids (content/at-cached B "post" 4)) + (list "h" "img" "p")) + +;; ── re-snapshot moves the cache forward ── +(content-test "re-snapshot seq" (content/snapshot! B "post") 6) +(content-test + "head-cached still correct after resnap" + (doc-ids (content/head-cached B "post")) + (list "h" "p" "q")) + +;; ── drop snapshot falls back to full replay, same result ── +(content/drop-snapshot! B "post") +(content-test "snapshot dropped" (content/has-snapshot? B "post") false) +(content-test + "head-cached == head after drop" + (doc-ids (content/head-cached B "post")) + (doc-ids (content/head B "post"))) + +;; ── snapshot of empty / fresh doc ── +(content-test + "snapshot empty doc seq 0" + (content/snapshot! B "empty") + 0) +(content-test + "head-cached empty" + (doc-ids (content/head-cached B "empty")) + (list)) diff --git a/plans/content-on-sx.md b/plans/content-on-sx.md index c9ba47d7..55ae2eac 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` → **318/318** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render+import, durable CRDT replication, validation) +`bash lib/content/conformance.sh` → **338/338** (Phases 1–4 COMPLETE + extensions: HTML/SX escaping, Markdown render+import, durable CRDT replication, validation, snapshot cache) ## Ground rules @@ -82,9 +82,18 @@ lib/content/api.sx ── (content/edit) (content/render) (content/history) ─ - [x] durable CRDT replication (`crdt-store.sx`: ops on persist, replay + converge) - [x] document validation (`validate.sx`: ids, per-type fields, duplicate ids) - [x] Markdown import adapter (`md-import.sx`: text → blocks, round-trips export) +- [x] snapshot cache over replay (`snapshot.sx`: cache-not-primary, transparent) ## Progress log +- 2026-06-07 — Extension: snapshot cache over op-log replay (`snapshot.sx`). + Snapshots are a cache, never primary state — the log stays the source of truth. + `content/snapshot!` stores a materialised head at a seq in the persist KV; + `content/head-cached` / `content/at-cached` start from the nearest snapshot and + replay only the tail, returning a document IDENTICAL to a full replay (tests + assert transparency before/after snapshot, across versions, and after + drop-snapshot fallback). `content/has-snapshot?` / `snapshot-seq` / + `drop-snapshot!`. 20 tests; suite 338/338. - 2026-06-07 — Extension: Markdown import adapter (`md-import.sx`), inverse of asMarkdown. Line-based parser: ATX headings, fenced code (```lang), blockquotes, unordered/ordered lists (grouping consecutive items), thematic breaks,