host: each-source = graph query — the data-driven each (composition roadmap step 3)

An object's `each` source can now be a GRAPH QUERY: `(query is-a TYPE)` resolves to
whatever is-a TYPE *right now* — the list isn't baked into the body, it's the live graph.
The object's `each` IS the query; the render is the run over current data (the unifying
property, now over real data).

compose.sx stays self-contained: the `query` source delegates to a resolver bound in the
render context under "query" — it asks the context for data, never reaching into the graph
itself. The host supplies graph access via host/blog--comp-query (`(query is-a TYPE)` ->
host/blog-instances-of -> full records) injected by host/blog--comp-ctx (auth + resolver);
the post handler renders :body against that context.

Added a `val` leaf — the raw field value with no markup wrapper, for use inside attributes
(href/src). `field` stays span-wrapped for display; `(val :slug)` makes a real link in the
each template. /compose-demo's each is now a live (query is-a compose-item) over two seeded
instances instead of a baked literal list.

Verified end-to-end via a focused harness eval over the full relations+persist+blog stack
(query iterates real instances; clean href via val; empty query -> empty, not an error).
Blog suite 151/153 — the 2 fails ("relate-options load-more sentinel", "related picker
offers all posts") are PRE-EXISTING (clean HEAD is 149/151 with the identical 2 fails, a
relate-picker pagination-boundary issue) and unrelated to composition; my 2 new tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 20:10:49 +00:00
parent bfb91819d9
commit 29aa7cd70f
4 changed files with 72 additions and 8 deletions

View File

@@ -527,11 +527,36 @@
(fn (slug body)
(let ((r (host/blog-get slug)))
(when r (host/blog--write! slug (merge r {:body body}))))))
;; The resolver for the composition `each` graph-query source (compose.sx asks the context
;; for "query"). `(query REL TYPE)` -> the objects related to TYPE by REL, as full records
;; so the per-item template can field them. Today the supported relation is is-a (TYPE's
;; transitive instances, via host/blog-instances-of); the dispatch leaves room for more.
;; This is the DATA-DRIVEN each source — the object's `each` is the query, the render is
;; the run over whatever the graph currently holds.
(define host/blog--comp-query
(fn (qargs ctx)
(let ((rel (str (first qargs))) (type (str (first (rest qargs)))))
(cond
((= rel "is-a") (map host/blog-get (host/blog-instances-of type)))
(else (list))))))
;; the render context for a :body: auth from the principal + the graph-query resolver.
(define host/blog--comp-ctx
(fn (principal)
(merge (if (nil? principal) {} {"auth" "yes"})
{"query" host/blog--comp-query})))
;; Seed a live demo of the composition fold: one object, rendered by host/comp-render, that
;; shows seq + alt(when auth) + row(par) + each — and renders DIFFERENTLY logged-in vs out.
(define host/blog-seed-compose-demo!
(fn ()
(begin
;; a demo type + two instances, so the each(query …) below iterates REAL graph data —
;; the list isn't baked into the body, it's whatever is-a compose-item right now.
(host/blog-seed! "compose-item" "Compose Item" "(article (h1 \"Compose Item\"))" "published")
(host/blog-relate! "compose-item" "type" "subtype-of")
(host/blog-seed! "compose-item-revel" "Revel Show" "(article (h1 \"Revel Show\"))" "published")
(host/blog-seed! "compose-item-pub" "Pub Night" "(article (h1 \"Pub Night\"))" "published")
(host/blog-relate! "compose-item-revel" "compose-item" "is-a")
(host/blog-relate! "compose-item-pub" "compose-item" "is-a")
(host/blog-seed! "compose-demo" "Composition Demo"
"(article (h1 \"Composition Demo\") (p \"Rendered via the composition fold.\"))" "published")
(host/blog--set-body! "compose-demo"
@@ -542,9 +567,9 @@
(text "<h3>Two columns (par)</h3>")
(row (text "<div style=\"flex:1;border:1px solid #ccc;padding:0.5em\">Column A</div>")
(text "<div style=\"flex:1;border:1px solid #ccc;padding:0.5em\">Column B</div>"))
(text "<h3>A list (each)</h3><ul>")
(each (items {:name "Revel Show" :date "Aug"} {:name "Pub Night" :date "Jun"})
(seq (text "<li>") (field :name) (text "") (field :date) (text "</li>")))
(text "<h3>A list (each over a graph query)</h3><ul>")
(each (query is-a compose-item)
(seq (text "<li><a href=\"/") (val :slug) (text "\">") (field :title) (text "</a></li>")))
(text "</ul>")))))))
;; replace every (field "name") node in a parsed template tree with values[name] ("" if
;; absent). Pure: a tree-walk over the already-parsed template + pre-fetched values.
@@ -1128,7 +1153,7 @@
;; (host/comp-render) against a context (auth from the principal); else the
;; legacy sx_content path. The SAME object renders differently per context.
(body-html (if (get r :body)
(host/comp-render (get r :body) (if (nil? principal) {} {"auth" "yes"}))
(host/comp-render (get r :body) (host/blog--comp-ctx principal))
(host/blog-render r)))
;; all relation blocks (Related, Tags, Types, Tagged-with-this …)
;; come from iterating the registry — one section, registry-driven.