host: relations-as-posts slice 5 — refinement types (schemas on the type-post)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s

A type-post carries its schema in a :schema slot (a list of {:block :msg} rules — a
refinement {x : T | x has these blocks}). host/blog-schema-of reads it off the post;
the hardcoded host/blog-type-schemas table is gone. A NEW refinement type is pure
data: give a type-post a :schema and its instances are validated on save — no code
(tested with a 'guide' type requiring a 'pre' block). article's schema is migrated
onto the article post at boot (host/blog--set-schema!, a single read+write).

host/blog-put! now MERGES over the previous record, so editing a post's
title/content doesn't nuke its :schema/:rel metadata (also closes the Slice 2
'edit drops :rel' gap). schema-of reads the post (a durable read) — only the SAVE
path calls it (a write request, never a render that would VmSuspend).

conformance 299/299 (+4: article h1 enforced from the post, a new refinement type
validates its instances, schema read off the post, edit preserves :schema).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 09:13:30 +00:00
parent d45da81b80
commit d8e951ed27
3 changed files with 70 additions and 16 deletions

View File

@@ -32,10 +32,15 @@
(fn (slug) (persist/backend-kv-get host/blog-store (host/blog--key slug))))
(define host/blog-exists?
(fn (slug) (persist/backend-kv-has? host/blog-store (host/blog--key slug))))
;; A write preserves any extra slots already on the record (:rel for relation-posts,
;; :schema for refinement types) — merging over the previous record — so editing a
;; post's title/content/status doesn't nuke the metadata that lives alongside it.
(define host/blog-put!
(fn (slug title sx-content status)
(persist/backend-kv-put host/blog-store (host/blog--key slug)
{:slug slug :title title :sx-content sx-content :status status})))
(let ((prev (host/blog-get slug)))
(persist/backend-kv-put host/blog-store (host/blog--key slug)
(merge (if prev prev {})
{:slug slug :title title :sx-content sx-content :status status})))))
(define host/blog-delete!
(fn (slug) (persist/backend-kv-delete host/blog-store (host/blog--key slug))))
(define host/blog-seed!
@@ -335,15 +340,26 @@
(define host/blog-tags (fn (slug) (host/blog-out slug "tagged"))) ;; a post's tags
(define host/blog-tagged-with (fn (tag) (host/blog-in tag "tagged"))) ;; posts with a tag
;; ── gradual validation: declarative type schemas ───────────────────
;; A type may carry a SCHEMA: a list of rules {:block <tag> :msg <why>}, each
;; requiring the content to contain (anywhere) an element of that tag. A post is
;; checked against the schema of every type it is-a; a type with no schema imposes
;; nothing (gradual). Schemas are declarative data (not opaque predicates) so they
;; yield a specific, human error — and could later be stored ON the type-post.
(define host/blog-type-schemas
{:article (list {:block "h1" :msg "an article needs a heading (h1)"})})
(define host/blog-schema-of (fn (type-slug) (get host/blog-type-schemas type-slug)))
;; ── gradual validation: refinement types (schemas ON the type-post) ──
;; A type-post may carry a SCHEMA in a :schema slot: a list of rules
;; {:block <tag> :msg <why>}, each requiring the content to contain (anywhere) an
;; element of that tag — i.e. a refinement type {x : T | x has these blocks}. A post
;; is checked against the schema of every type it is-a; a type with no schema imposes
;; nothing (gradual). Schemas are declarative data (not opaque predicates), so they
;; yield a specific, human error AND live on the type-post (Slice 5) — make a new
;; refinement type by giving its post a :schema (host/blog--set-schema!).
;; schema-of reads the type-post; only the SAVE path calls it (a write request, where
;; a durable read is fine — never in a render, which would VmSuspend).
(define host/blog-schema-of (fn (type-slug) (get (host/blog-get type-slug) :schema)))
;; attach/replace a type-post's :schema (idempotent; preserves the rest of the record).
;; Used at boot to install schemas on type-posts — incl. migrating ones seeded before
;; schemas lived on the post (a single read+write, not a loop, so boot-JIT-safe).
(define host/blog--set-schema!
(fn (slug schema)
(let ((r (host/blog-get slug)))
(when r
(persist/backend-kv-put host/blog-store (host/blog--key slug)
(merge r {:schema schema}))))))
;; every element tag in a parsed content tree, recursively (the heads of nested
;; lists) — so "requires h1" matches an h1 even inside an article/section wrapper.
@@ -417,6 +433,8 @@
"(article (h1 \"Article\") (p \"A kind of post that must have a heading. A post that is-a article is checked against this type's schema on save — gradual typing: declaring the type adds the requirement, and the next edit must satisfy it.\"))"
"published")
(host/blog-relate! "article" "type" "subtype-of")
;; article's schema lives ON the article post now (Slice 5) — install/migrate it.
(host/blog--set-schema! "article" (list {:block "h1" :msg "an article needs a heading (h1)"}))
;; relation DECLARATIONS (see plans/relations-as-posts.md). A type-post declares
;; which relation it anchors at its OBJECT end ("you may point at me with R"); the
;; picker's candidate set is the down-closure of a relation's anchors through the