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

@@ -750,6 +750,31 @@
(list "ANON<span>X</span><span>Y</span>" "MEMBER<span>X</span><span>Y</span>"))
(host-bl-test "post page renders :body (composition) over sx_content"
(contains? (dream-resp-body (host-bl-app (host-bl-req "/cdoc/"))) "ANON") true)
;; -- the each source can be a GRAPH QUERY: the list isn't baked into the body, it's
;; whatever is-a the type right now (data-driven). The resolver (host/blog--comp-query)
;; is injected into the render context by host/blog--comp-ctx. --
(host-bl-test "each(query is-a TYPE) iterates real graph instances"
(begin
(host/blog-seed! "qtype" "QType" "(p \"t\")" "published")
(host/blog-relate! "qtype" "type" "subtype-of")
(host/blog-seed! "qi-1" "Item One" "(p \"1\")" "published")
(host/blog-seed! "qi-2" "Item Two" "(p \"2\")" "published")
(host/blog-relate! "qi-1" "qtype" "is-a")
(host/blog-relate! "qi-2" "qtype" "is-a")
(let ((out (host/comp-render
(quote (each (query is-a qtype)
(seq (text "<a href=\"/") (val :slug) (text "\">") (field :title) (text "</a>"))))
(host/blog--comp-ctx nil))))
;; field wraps in <span> (display); val is raw (for the href attribute).
(list (contains? out "Item One") (contains? out "Item Two")
(contains? out "/qi-1") (contains? out "<span>Item One</span>"))))
(list true true true true))
;; a query with no matching instances renders empty (not an error) — robustness.
(host-bl-test "each(query is-a TYPE) with no instances renders empty"
(host/comp-render
(quote (each (query is-a no-such-type) (field :title)))
(host/blog--comp-ctx nil))
"")
(host-bl-test "a post with no schema'd type is vacuously valid"
(host/blog-type-valid? "ppost" "(p \"anything\")") true)
(host-bl-test "edit-submit rejects content violating the type schema (not saved)"