host: relations-as-posts slice 2 — relation metadata lives on relation-posts
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s

is-a/subtype-of/tagged/related are now POSTS (each is-a a new `relation` root),
owning their metadata in a :rel slot {:symmetric :label :inverse-label}. The static
host/blog-rel-kinds registry is gone: kind-spec/rel-kinds/kind-symmetric? read the
relation-posts (via an in-memory cache), and the relation list derives from
host/blog-in "relation" "is-a".

Perform-budget fixes (a durable read inside the http-listen render VM raises
VmSuspended; too many per request 500s the page):
 - relation metadata is loaded into a cache at boot (host/blog-load-rel-kinds!,
   like load-edges!), so kind-spec is pure on render paths;
 - the initial edit page renders its pickers EMPTY (the load trigger fills each) —
   only the relate/unrelate FRAGMENT server-renders candidates (with-cands flag).
   Previously every edit page render did candidate-get × 4 pickers and 500'd.

host conformance 287/287 (+4 slice-2: kind-spec reads :rel, kind-symmetric? off the
post, unknown kind has no spec, rel-kinds derived from the graph). run-picker-check
3/3 (edit page boots, relate/unrelate flow works, no client errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 22:49:59 +00:00
parent b3804ce712
commit c6627f4954
4 changed files with 116 additions and 45 deletions

View File

@@ -94,20 +94,25 @@
;; type-post, and types ARE posts (same blog:<slug> namespace). ;; type-post, and types ARE posts (same blog:<slug> namespace).
(define host/blog--node (fn (slug) (string->symbol (str "blog:" slug)))) (define host/blog--node (fn (slug) (string->symbol (str "blog:" slug))))
;; Relations are POSTS (plans/relations-as-posts.md). Each relation-post is-a
;; `relation` and owns its metadata in a :rel slot {:symmetric :label
;; :inverse-label}. To keep RENDER paths perform-free — a durable read inside the
;; http-listen render VM raises VmSuspended — the relation specs are loaded into an
;; in-memory cache at boot, exactly like edges (host/blog-load-edges!). kind-spec /
;; rel-kinds / kind-symmetric? then read the cache (pure); the relation-posts stay
;; the durable source of truth. host/blog-load-rel-kinds! re-reads them.
(define host/blog--rel-cache (dict))
(define host/blog-load-rel-kinds!
(fn ()
(for-each
(fn (kind)
(let ((m (get (host/blog-get kind) :rel)))
(when m (dict-set! host/blog--rel-cache kind (merge {:kind kind} m)))))
(host/blog-in "relation" "is-a")))) ;; relations are flat: direct is-a is enough
;; spec = the cached :rel metadata + :kind; nil for a non-relation (relate validates).
(define host/blog--kind-spec (fn (kind) (get host/blog--rel-cache kind)))
(define host/blog-rel-kinds (define host/blog-rel-kinds
(list (fn () (map (fn (k) (get host/blog--rel-cache k)) (keys host/blog--rel-cache))))
{:kind "related" :label "Related posts" :symmetric true :candidates "all"}
{:kind "is-a" :label "Types" :symmetric false :candidates "types"
:inverse-label "Instances"}
{:kind "subtype-of" :label "Subtype of" :symmetric false :candidates "types"
:inverse-label "Subtypes"}
{:kind "tagged" :label "Tags" :symmetric false :candidates "tags"
:inverse-label "Tagged with this"}))
;; registry lookup; nil for an unknown kind (relate validates against this)
(define host/blog--kind-spec
(fn (kind)
(reduce (fn (acc k) (if (= (get k :kind) kind) k acc)) nil host/blog-rel-kinds)))
(define host/blog--kind-symmetric? (define host/blog--kind-symmetric?
(fn (kind) (let ((s (host/blog--kind-spec kind))) (and s (get s :symmetric))))) (fn (kind) (let ((s (host/blog--kind-spec kind))) (and s (get s :symmetric)))))
@@ -303,6 +308,19 @@
(define host/blog-type-valid? (define host/blog-type-valid?
(fn (slug content) (= (len (host/blog-type-issues slug content)) 0))) (fn (slug content) (= (len (host/blog-type-issues slug content)) 0)))
;; Seed a relation-post: a post that is-a `relation` and carries its metadata in a
;; :rel slot. Idempotent (the record is written once; the is-a edge is a set).
(define host/blog--seed-rel!
(fn (slug title symmetric label inverse-label)
(begin
(when (not (host/blog-exists? slug))
(persist/backend-kv-put host/blog-store (host/blog--key slug)
{:slug slug :title title
:sx-content (str "(article (h1 \"" title "\") (p \"A relation — posts link to each other through it. Its symmetry and labels live on this post.\"))")
:status "published"
:rel {:symmetric symmetric :label label :inverse-label inverse-label}}))
(host/blog-relate! slug "relation" "is-a"))))
;; Seed the root type-posts: "type" (the root) and "tag" (a kind of type). Types ;; Seed the root type-posts: "type" (the root) and "tag" (a kind of type). Types
;; ARE posts, so these are real posts that document themselves; tag subtype-of ;; ARE posts, so these are real posts that document themselves; tag subtype-of
;; type means anything that is-a tag is, transitively, a type. Idempotent — safe ;; type means anything that is-a tag is, transitively, a type. Idempotent — safe
@@ -310,6 +328,15 @@
(define host/blog-seed-types! (define host/blog-seed-types!
(fn () (fn ()
(begin (begin
;; relations are posts too — `relation` is their root; each relation-post
;; is-a relation and owns its symmetry + labels (plans/relations-as-posts.md).
(host/blog-seed! "relation" "Relation"
"(article (h1 \"Relation\") (p \"The root of relations. A relation is a typed edge between posts; each relation-post declares its symmetry and labels, and a type anchors its object end (which gives the picker its candidates).\"))"
"published")
(host/blog--seed-rel! "related" "related" true "Related posts" nil)
(host/blog--seed-rel! "is-a" "is a" false "Types" "Instances")
(host/blog--seed-rel! "subtype-of" "subtype of" false "Subtype of" "Subtypes")
(host/blog--seed-rel! "tagged" "tagged" false "Tags" "Tagged with this")
(host/blog-seed! "type" "Type" (host/blog-seed! "type" "Type"
"(article (h1 \"Type\") (p \"The root type. Types are posts — so this is a post that documents the idea of a type. A post declares its types with is-a edges; types form a hierarchy with subtype-of edges.\"))" "(article (h1 \"Type\") (p \"The root type. Types are posts — so this is a post that documents the idea of a type. A post declares its types with is-a edges; types form a hierarchy with subtype-of edges.\"))"
"published") "published")
@@ -620,30 +647,35 @@
;; per kind). The picker's candidates come from the kind's registry :candidates ;; per kind). The picker's candidates come from the kind's registry :candidates
;; ("all" / tags / types). ;; ("all" / tags / types).
(define host/blog--relation-editor (define host/blog--relation-editor
(fn (slug kind) (fn (slug kind with-cands)
;; current edges read up front (a perform) — NOT inside the quasiquote, where ;; current edges read up front (a perform) — NOT inside the quasiquote, where
;; a perform would raise VmSuspended under http-listen. ;; a perform would raise VmSuspended under http-listen.
(let ((spec (host/blog--kind-spec kind)) (let ((spec (host/blog--kind-spec kind))
(current (host/blog-out slug kind)) (current (host/blog-out slug kind))
;; the results <ul>, server-rendered with the first page of candidates so a ;; results <ul>. When `with-cands` (the relate/unrelate fragment), the first
;; re-rendered editor's picker is never briefly empty (the load trigger then ;; page of candidates is server-rendered in, so the re-rendered picker is
;; re-fetches the same page and morphs it in, invisibly). Built by cons so ;; never briefly empty (the load trigger then re-fetches the same page and
;; the candidate li-trees splice in as children (the same pattern the current ;; morphs it in, invisibly). On the INITIAL edit page it renders EMPTY and the
;; list uses) — they can't be passed through a component arg (those evaluate). ;; load trigger fills it — server-rendering candidates for EVERY kind's picker
;; would do a durable read per candidate × every editor, blowing the
;; http-listen render budget (VmSuspended). Built by cons so candidate
;; li-trees splice in as children (component args would evaluate them).
(results-ul (results-ul
(let ((cands (take (host/blog--relate-candidates slug "" kind) (let ((rows (if with-cands
host/blog--picker-limit))) (let ((cands (take (host/blog--relate-candidates slug "" kind)
(let ((rows (append host/blog--picker-limit)))
(map (fn (p) (host/blog--picker-item slug p kind)) cands) (append
(if (= (len cands) host/blog--picker-limit) (map (fn (p) (host/blog--picker-item slug p kind)) cands)
(list (host/blog--picker-more slug kind "" host/blog--picker-limit)) (if (= (len cands) host/blog--picker-limit)
(list))))) (list (host/blog--picker-more slug kind "" host/blog--picker-limit))
(cons (quote ul) (list))))
(append (list))))
(quasiquote (:id (unquote (str "rp-" kind "-results")) (cons (quote ul)
:class "rp-results" (append
:style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd")) (quasiquote (:id (unquote (str "rp-" kind "-results"))
rows)))))) :class "rp-results"
:style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd"))
rows)))))
(quasiquote (quasiquote
;; #rel-editor-KIND wraps the WHOLE editor (current list + picker) so relate ;; #rel-editor-KIND wraps the WHOLE editor (current list + picker) so relate
;; and unrelate can re-render it with one outerHTML swap — keeping the two ;; and unrelate can re-render it with one outerHTML swap — keeping the two
@@ -717,8 +749,10 @@
(define host/blog--relation-editors (define host/blog--relation-editors
(fn (slug) (fn (slug)
(cons (quote div) (cons (quote div)
(map (fn (spec) (host/blog--relation-editor slug (get spec :kind))) ;; false: the initial edit page renders empty pickers (the load trigger fills
host/blog-rel-kinds)))) ;; each), keeping this render cheap. The relate/unrelate FRAGMENT passes true.
(map (fn (spec) (host/blog--relation-editor slug (get spec :kind) false))
(host/blog-rel-kinds)))))
;; ── read handlers ─────────────────────────────────────────────────── ;; ── read handlers ───────────────────────────────────────────────────
;; Post body is rendered per-block (a guarded HTML string) then injected raw. ;; Post body is rendered per-block (a guarded HTML string) then injected raw.
@@ -932,7 +966,7 @@
(begin (begin
(for-each (fn (o) (host/blog-unrelate! slug o kind)) (host/blog-out slug kind)) (for-each (fn (o) (host/blog-unrelate! slug o kind)) (host/blog-out slug kind))
(for-each (fn (o) (host/blog-unrelate! o slug kind)) (host/blog-in slug kind))))) (for-each (fn (o) (host/blog-unrelate! o slug kind)) (host/blog-in slug kind)))))
host/blog-rel-kinds))) (host/blog-rel-kinds))))
(define host/blog-delete-handler (define host/blog-delete-handler
(fn (req) (fn (req)
@@ -967,7 +1001,7 @@
;; its candidates. text/html so the client's DOMParser swap path renders the ;; its candidates. text/html so the client's DOMParser swap path renders the
;; already-expanded fragment. Plain boosted form / no-JS still redirects. ;; already-expanded fragment. Plain boosted form / no-JS still redirects.
(if (host/blog--editor-swap-req? req) (if (host/blog--editor-swap-req? req)
(dream-html (render-page (host/blog--relation-editor slug kind))) (dream-html (render-page (host/blog--relation-editor slug kind true)))
(dream-redirect (str "/" slug "/edit")))))))) (dream-redirect (str "/" slug "/edit"))))))))
;; POST /<slug>/unrelate — remove the relation to `other` under `kind` (default ;; POST /<slug>/unrelate — remove the relation to `other` under `kind` (default
@@ -986,7 +1020,7 @@
;; (re-loaded) candidate pool, and the picker is NOT cleared. A plain boosted ;; (re-loaded) candidate pool, and the picker is NOT cleared. A plain boosted
;; form (the tag toggle) or a no-JS POST still redirects + re-renders #content. ;; form (the tag toggle) or a no-JS POST still redirects + re-renders #content.
(if (host/blog--editor-swap-req? req) (if (host/blog--editor-swap-req? req)
(dream-html (render-page (host/blog--relation-editor slug kind))) (dream-html (render-page (host/blog--relation-editor slug kind true)))
(dream-redirect (str "/" slug "/edit"))))))) (dream-redirect (str "/" slug "/edit")))))))
;; GET /<slug>/edit — edit form pre-filled with the post's current title, raw ;; GET /<slug>/edit — edit form pre-filled with the post's current title, raw

View File

@@ -151,6 +151,11 @@ EPOCH=1
echo "(epoch $EPOCH)" echo "(epoch $EPOCH)"
echo "(eval \"(host/blog-seed-types!)\")" echo "(eval \"(host/blog-seed-types!)\")"
EPOCH=$((EPOCH+1)) EPOCH=$((EPOCH+1))
# Load relation metadata (symmetry/labels) from the relation-posts into the
# in-memory cache, so render paths read it without a (VmSuspending) durable read.
echo "(epoch $EPOCH)"
echo "(eval \"(host/blog-load-rel-kinds!)\")"
EPOCH=$((EPOCH+1))
# Index the web-stack .sxbc by content hash so /sx/h/{hash} can serve them # Index the web-stack .sxbc by content hash so /sx/h/{hash} can serve them
# immutably and the shell can emit the data-sx-manifest (content-addressed # immutably and the shell can emit the data-sx-manifest (content-addressed
# client module cache). Done once at boot. # client module cache). Done once at boot.

View File

@@ -213,6 +213,11 @@
;; -- relate posts (blog × relations) -- ;; -- relate posts (blog × relations) --
;; my-first-post and another-one both exist in the write-test store at this point. ;; my-first-post and another-one both exist in the write-test store at this point.
;; Relations are posts now (their symmetry/labels live on relation-posts), so seed
;; them up front exactly as boot does (serve.sh) before exercising relate, and load
;; the relation metadata into the in-memory cache the same way.
(host/blog-seed-types!)
(host/blog-load-rel-kinds!)
(host-bl-test "relate no auth -> redirect to login" (host-bl-test "relate no auth -> redirect to login"
(dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" nil (dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" nil
"application/x-www-form-urlencoded" "other=another-one"))) 303) "application/x-www-form-urlencoded" "other=another-one"))) 303)
@@ -276,7 +281,7 @@
;; "load" and on debounced "input". The SX engine re-binds these triggers on ;; "load" and on debounced "input". The SX engine re-binds these triggers on
;; swapped content, so it works on a full load AND a boosted SPA nav. ;; swapped content, so it works on a full load AND a boosted SPA nav.
(host-bl-test "picker form is declaratively wired to relate-options (load + debounced input)" (host-bl-test "picker form is declaratively wired to relate-options (load + debounced input)"
(let ((html (render-page (host/blog--relation-editor "alpha-post" "related")))) (let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
(list (contains? html "/alpha-post/relate-options") (list (contains? html "/alpha-post/relate-options")
(contains? html "input delay:200ms, load") (contains? html "input delay:200ms, load")
(contains? html "rp-related-results"))) (contains? html "rp-related-results")))
@@ -285,7 +290,7 @@
;; <ul>, so a re-rendered editor is never briefly empty (no flash). The candidate row ;; <ul>, so a re-rendered editor is never briefly empty (no flash). The candidate row
;; for an existing post appears inside the results ul. ;; for an existing post appears inside the results ul.
(host-bl-test "editor server-renders the first page of candidates into the picker" (host-bl-test "editor server-renders the first page of candidates into the picker"
(let ((html (render-page (host/blog--relation-editor "alpha-post" "related")))) (let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
(list (contains? html "id=\"cand-related-") ;; a candidate row is present (list (contains? html "id=\"cand-related-") ;; a candidate row is present
(contains? html "Beta Post"))) ;; an unrelated post is offered (contains? html "Beta Post"))) ;; an unrelated post is offered
(list true true)) (list true true))
@@ -313,7 +318,7 @@
(host/blog-relate! "alpha-post" "beta-post" "related") (host/blog-relate! "alpha-post" "beta-post" "related")
;; the editor wraps current list + picker in #rel-editor-KIND; remove re-renders it ;; the editor wraps current list + picker in #rel-editor-KIND; remove re-renders it
(host-bl-test "relation-editor wires remove to re-render the kind's editor" (host-bl-test "relation-editor wires remove to re-render the kind's editor"
(let ((html (render-page (host/blog--relation-editor "alpha-post" "related")))) (let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
(list (contains? html "id=\"rel-editor-related\"") ;; the swap target (list (contains? html "id=\"rel-editor-related\"") ;; the swap target
(contains? html "sx-post=\"/alpha-post/unrelate\"") ;; AJAX, not plain post (contains? html "sx-post=\"/alpha-post/unrelate\"") ;; AJAX, not plain post
(contains? html "sx-target=\"#rel-editor-related\"") (contains? html "sx-target=\"#rel-editor-related\"")
@@ -481,6 +486,22 @@
(contains? (dream-resp-body (host-bl-app (host-bl-req "/ppost/relate-options?kind=is-a"))) "Article") (contains? (dream-resp-body (host-bl-app (host-bl-req "/ppost/relate-options?kind=is-a"))) "Article")
true) true)
;; -- relations are posts: symmetry + labels read off the relation-posts (slice 2) --
(host-bl-test "kind-spec reads :rel metadata off the relation-post"
(let ((s (host/blog--kind-spec "is-a")))
(list (get s :kind) (get s :label) (get s :symmetric) (get s :inverse-label)))
(list "is-a" "Types" false "Instances"))
(host-bl-test "kind-symmetric? reads symmetry off the post (related yes, is-a no)"
(list (host/blog--kind-symmetric? "related") (host/blog--kind-symmetric? "is-a"))
(list true false))
(host-bl-test "an unknown kind has no spec, so relate still validates it away"
(host/blog--kind-spec "bogus-kind") nil)
(host-bl-test "rel-kinds is DERIVED from the graph (every post that is-a relation)"
(let ((kinds (map (fn (s) (get s :kind)) (host/blog-rel-kinds))))
(list (contains? kinds "related") (contains? kinds "is-a")
(contains? kinds "subtype-of") (contains? kinds "tagged")))
(list true true true true))
;; -- Phase 3: tags as posts -- (ocaml is-a tag, from the seed-types test above) ;; -- Phase 3: tags as posts -- (ocaml is-a tag, from the seed-types test above)
(host-bl-test "is-tag?: a post that is-a tag is a tag; others are not" (host-bl-test "is-tag?: a post that is-a tag is a tag; others are not"
(list (host/blog-is-tag? "ocaml") (host/blog-is-tag? "ppost")) (list (host/blog-is-tag? "ocaml") (host/blog-is-tag? "ppost"))

View File

@@ -40,11 +40,22 @@ the relation's object-end declaration from the anchor**, which includes the root
`host/blog--candidate-pool` to be declaration-driven. `:candidates` becomes vestigial. `host/blog--candidate-pool` to be declaration-driven. `:candidates` becomes vestigial.
- Wrinkle fixed: the type roots now appear as `is-a` candidates. - Wrinkle fixed: the type roots now appear as `is-a` candidates.
### Slice 2 — relations as first-class posts ### Slice 2 — relations as first-class posts — DONE
- Seed `is-a`/`subtype-of`/`tagged`/`related` as posts that own their metadata - `relation` root + `is-a`/`subtype-of`/`tagged`/`related` seeded as posts (each is-a
(`:symmetric`, `:label`, `:inverse-label`, **cardinality**, **end roles**). The registry relation) owning their metadata in a `:rel` slot (`:symmetric :label :inverse-label`).
`host/blog-rel-kinds` melts into reads off these posts. A relation can declare its `host/blog-rel-kinds` / `kind-spec` / `kind-symmetric?` now read it; the static registry
*subject*-end anchor too (who may be the source), not just object. is gone. `host/blog--rel-slugs` = `host/blog-in "relation" "is-a"` (cheap, flat).
- **Perform budget under http-listen (the hard lesson):** a durable read inside the
render VM raises `VmSuspended`, and too many per request 500s the page. Two fixes:
(1) relation metadata is loaded into an in-memory cache at boot (`host/blog-load-rel-kinds!`,
like `load-edges!`) so `kind-spec` is pure; (2) the initial edit page renders its pickers
EMPTY (the load trigger fills each) — only the relate/unrelate FRAGMENT server-renders
candidates (`with-cands` flag), so one page render doesn't do `candidate-get × every
picker`. Benign single-perform suspend/resume still logs `VmSuspended` but returns 200.
- **Follow-up (Slice 2.5):** `relate-candidates` does a `host/blog-get` per pool member
(O(posts) for `related`). A boot-time **title cache** (updated on put!/delete!) would make
the picker O(1)-perform and cut the suspend/resume churn. Subject-end declarations + a
proper relation-subtype closure (when relations get subtyped) also belong here.
### Slice 3 — typed relations (target-type constraints) ### Slice 3 — typed relations (target-type constraints)
- A declaration carries a **target-type constraint**: the *other* end must be (an instance - A declaration carries a **target-type constraint**: the *other* end must be (an instance