Commit Graph

7 Commits

Author SHA1 Message Date
bdc7e02fbc host: content-addressed SPA cache + declarative SX-htmx relate picker + SIGPIPE hardening
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Three composing pieces that make the blog SPA correct and resilient.

Content-addressed module cache (lib/host/static.sx, serve.sh, blog.sx shell,
conformance.sh): index each web-stack .sxbc by the content hash in its head,
serve GET /sx/h/{hash} immutable text/sx, and emit <script data-sx-manifest>
{file->hash} so the WASM client loads modules content-addressed (localStorage +
immutable) instead of path + max-age. serve.sh builds the index at boot;
conformance.sh now loads static.sx before blog.sx (the shell calls
host/static-manifest-json).

Declarative relate picker (lib/host/blog.sx, lib/dream/form.sx): replace the
inline /relate-picker.js blob — which never ran on swapped-in content, so the
candidate list was empty after a boosted nav to /<slug>/edit — with a declarative
SX-htmx form: sx-get relate-options on "load" + debounced "input", innerHTML-swap
the results ul; infinite scroll via a server-emitted "load more" sentinel
(sx-trigger revealed, sx-swap outerHTML) that pages the rest, q preserved via a
new symmetric dr/url-encode. The engine re-binds these triggers on swapped
content, so the picker populates on full load AND boosted SPA nav. Candidate
relate forms get :sx-disable (plain POST->303->reload, their original behavior;
the engine would otherwise boost them and swap the redirect unreliably).
sx-retry "exponential:1000:30000" on the form+sentinel retries a dropped/offline
fetch forever (the cap bounds the interval, not the attempts).

SIGPIPE hardening (hosts/ocaml/bin/sx_server.ml): the native http-listen server
had no SIGPIPE handler, so a client aborting an in-flight fetch (the engine
cancels superseded requests on a debounced filter/fast nav) closed the socket
mid-write and killed the whole process (exit 141). Ignore SIGPIPE so the failed
write becomes a catchable Sys_error the per-connection handler already swallows.

Tests: host conformance 272/272; relate-picker.spec.js 5/5 incl. a boosted-nav
populate regression; spa-check 4/4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:30:17 +00:00
b9a24d5870 web: re-boost swapped content from the [sx-boost] ancestor (fixes back-then-click full reload)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 33s
After a fragment swap, process-elements(target) -> process-boosted(target) only
boosted [sx-boost] containers that are DESCENDANTS of the swap target. But the
swap target (#content) is nested UNDER the boost wrapper (<div sx-boost="#content">
<div id="content">), so re-boosting scoped to the target found nothing — the
swapped-in links never got bound. Only the initial document-wide boot boost
worked, so: home->sub worked (home links boosted at boot), but Back restored the
home content unboosted, and the next click did a full page reload. (Post-page
links were unboosted too; Back just exposed it.)

process-boosted now ALSO boosts from the nearest [sx-boost] ANCESTOR of root
(dom-closest), so any swap target inside a boost scope gets its links rebound.
is-processed? guards keep it idempotent.

spa-check: the back-button test now clicks AGAIN after Back and asserts it's a
SPA nav (no full reload) — would have caught this. .sxbc regenerated.

Verified: spa-check 4/4 (incl. click-after-back).
2026-06-29 13:41:50 +00:00
f5b6612ee1 web+host: fix raw! HTML dropped in client SX render (dom-parse-html returned a NodeList)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s
dom-parse-html returned body.childNodes — a NodeList, not a Node — so the client
SX render did appendChild(NodeList) and threw "Argument 1 does not implement
interface Node", silently dropping every raw! HTML block (e.g. a post's <article>
body). It surfaced only now because the blog renders fragments client-side
(text/sx) since this session; before, fragments were server HTML so sx-render
never ran on raw!. The error is caught/non-fatal, and the spa-check suite only
asserted the footer + URL behaviour, so it passed through a dropped post body.

- dom-parse-html now returns a DocumentFragment (moves the parsed nodes in): a
  real Node, appendChild-able as one unit, and queryable — which also fixes the
  already-broken hs-htmx callers that did (dom-query doc ...) / (dom-first-child
  doc) on what was a NodeList.
- spa-check: assert #content article is visible after a boosted nav, so a dropped
  post body fails the suite (closes the test gap).
- .sxbc regenerated; bundle dom.sx synced to canonical web/lib/dom.sx.

Verified: spa-check 4/4 (incl. the new article assertion).
2026-06-29 13:27:13 +00:00
689dae7d0c host+kernel: blog SPA boost works end-to-end on the WASM OCaml kernel (Playwright 4/4)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
Clicking a blog link now fragment-swaps #content with URL push + working back
button, no full reload — the SX-htmx engine driving the same OCaml kernel the
server runs. Six bugs in the source-load + boost path, found by bisecting in
chromium, all fixed:

1. Import double-apply (sx_server.ml x2, sx_browser.ml): the import suspension
   handlers computed `key = library_name_key lib_spec` then called
   `library_loaded_p key` — but library_loaded_p applies library_name_key
   itself, so it ran sx_to_list on a string and crashed ("Expected list, got
   string"). Only unloaded libs suspend, so it only bit lazy imports. Pass the
   spec, not the key.

2. Unloaded-import crash (spec/evaluator.sx + sx_ref.ml library_exports): an
   import of a not-yet-loaded library returned nil exports, and bind-import-set
   did (keys nil) -> crash. Return an empty dict so the import is a graceful
   no-op (lazy symbol resolution covers real usage).

3. value_to_js missing Integer (sx_browser.ml): integers passed to host methods
   were mishandled, so dom-query-all's (host-call node-list "item" i) ignored i
   and returned node 0 for every index — every element aliased the first, so
   only one link ever boosted. Add the Integer -> JS number case.

4. browser-same-origin? rejected relative URLs (browser.sx x2): it only did
   (starts-with? url origin), so "/alpha/" was treated as cross-origin and
   should-boost-link? refused every relative link. Accept scheme-less,
   non-protocol-relative URLs.

5. dom-query-in undefined (orchestration.sx x2): the swap path called a function
   that exists nowhere; it's just dom-query with a container arg.

6. Lazy-deps never loaded under source fallback (sx-platform.js): lazy symbol
   resolution only fires on the VM GLOBAL_GET path, but source-loaded swap
   callbacks run on the CEK and raise instead of lazy-loading, so the post-swap
   hs-boot-subtree!/htmx-boot-subtree! were undefined and aborted URL push.
   Preload the manifest's lazy-deps.

Verified: native host conformance 271/271; lib/host/playwright/spa-check 4/4
(boot, boost, fragment swap + URL push, back button) in real chromium against an
ephemeral durable host server.
2026-06-29 11:09:11 +00:00
dbcbc39ebe host: blog SPA scaffolding (WASM kernel) — server side complete, boost blocked on bundle rebuild
Turn the blog into a SPA using the SX-htmx engine (web/engine.sx) booting the
WASM OCaml kernel (same evaluator as the server) in-browser, with sx-boost
fragment-swapping every link into #content.

Server side DONE + verified:
- lib/host/static.sx: GET /static/** serves shared/static via the file-read
  primitive (ctype by ext, traversal-guarded, 404 on missing). Wired into
  serve.sh (module + route group). Tested: kernel JS + .wasm binary-exact.
- host/blog--page is now the SPA shell: full page = WASM boot scripts +
  sx-boost=#content wrapper + #content; on SX-Request:true returns ONLY the
  inner content fragment for the engine to swap. All 13 handlers thread req.
- docker-compose mounts ./shared/static.
- lib/host/playwright/spa-check.{spec.js,run-spa-check.sh}: boot/boost/swap/back.

Client side: the WASM kernel BOOTS (SxKernel object, data-sx-ready=true, web
stack loads). BLOCKER: the bundled .sxbc throw 'VM: unknown opcode 0' vs this
worktree's kernel -> .sx source fallback -> boot.sx source fails 'Expected
list, got string' -> process-boosted never binds links (boosted 0/N). Fix =
rebuild a consistent WASM bundle (recompile .sxbc against the kernel via
scripts/sx-build-all.sh); the browser wasm target isn't built here yet. See
plans/host-spa.md. Live NOT redeployed (stays on pre-SPA process).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:53:06 +00:00
62b7fc1ff0 host: typed relations — Phase 3, tags as posts
A tag is just a post that is-a tag; tagging is a "tagged" edge to it. End to end:
mark a post a tag, tag posts with it, see a post's tags and a tag's members.

- helpers: host/blog-is-tag? (= is-a? slug "tag"), host/blog-tags (out tagged),
  host/blog-tagged-with (in tagged), host/blog-instances-of (a type's members,
  O(#subtypes) not O(#posts) — the efficient candidate source).
- picker generalised to be KIND-AWARE and MULTI-INSTANCE: relate-options takes
  &kind=, candidates come from the kind's registry :candidates (all/tags/types);
  /relate-picker.js wires every .relate-picker box by data-kind (a Related picker
  and a Tags picker now coexist on the edit page).
- render: post page gains a "Tags" block; a tag post additionally lists "Tagged
  with this" (its members). edit page: a Related editor + a Tags editor + an
  "is this post a tag" toggle (reuses /relate kind=is-a — no new route).
- GOTCHA (again): host/blog--relation-editor read host/blog-out INSIDE its
  quasiquote -> VmSuspended/500 under http-listen + durable edges; moved the read
  to a let before the quasiquote (conformance can't see it — in-memory store;
  the ephemeral Playwright run caught it).

6 conformance tests (is-tag?, instances-of, tag+tagged-with, tagged picker offers
only tags, related picker still all, is-a-tag toggle) -> 261/261. Playwright
multi-picker 4/4. Verified live: ocaml made a tag, welcome tagged ocaml, Tags
block + Tagged-with-this both render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:09:53 +00:00
697931bf41 host: Playwright check for the relate picker (+ 2 bugs it caught)
Wire a browser check for the picker, run it against an ephemeral host server,
and fix the two real bugs it surfaced.

- lib/host/playwright/relate-picker.spec.js — drives login-redirect-return,
  JS candidate load + infinite scroll, debounced filter, and click-to-relate
  (asserting the relation shows on the post page).
- lib/host/playwright/run-picker-check.sh — spins up an ephemeral host server
  (this worktree's binary + lib, temp persist), seeds a host post + 25
  candidates, runs the spec in the main worktree's Playwright/chromium, tears
  everything down. No live-site dependency, no live-data pollution. 4/4 pass.

Bugs the check caught:
1. Query params weren't %-decoded — dream's form parser decodes but its query
   parser doesn't, so a filter "Item 13" arrived as "Item%2013" and matched
   nothing. Fix: decode q with dream's own dr/url-decode in host/blog-relate-
   options. (+ conformance test for a spaced filter.)
2. A filter typed while a load was in flight got dropped (busy guard returned
   with no trailing fetch). Fix: a `pending` flag re-runs the load when the
   in-flight one finishes, coalescing to the latest query.

239/239 conformance; JS node --check clean. Verified live: spaced filter
returns matches; served JS carries the pending-reload fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:07:47 +00:00