# Getting Started with Statum ## What is Statum Statum is a stateful web application framework for Vala, built on top of [Astralis](https://github.com/search/astralis) (HTTP server), [Invercargill](https://github.com/search/invercargill) (data structures / JSON), and [Inversion](https://github.com/search/inversion) (dependency injection). It provides: - **Server-rendered HTML pages** composed from templates and fragments at build time (`statum-mkpstm`), precompressed with gzip/zstd/brotli and served with encoding negotiation and ETags. - **Client-carried state** — signed and (for private data) encrypted snapshots that the browser holds and sends back, so the server is stateless between requests (no session store, no database for ephemeral state). - **Entrypoint hydration** — after a page loads, a lightweight JS client fetches directives that set the initial slot data and bind the DOM. - **GUID actions** — POST/GET handlers auto-mapped to `/_statum/action/{guid}`, invoked from the client via `stm-action` attributes, carrying strongly-typed private data. - **Realtime updates** — a SharedWorker-backed SSE channel pushes slot changes to subscribed tabs. ### Architecture at a glance ``` Browser Server ─────── ────── GET / ──────────────────────► StatumPage (precompressed HTML) page loads, the statum client modules boot GET /_statum/entrypoint ────► StatumEntrypoint → directives (set/clear/...) client applies directives client binds stm-* attributes POST /_statum/action/{guid} ► StatumAction → directives (with X-Statum-Slot headers) (mutate state, push to channels) ``` --- ## Quick Start Examples ### 1. A page with an entrypoint **`home.html`** — the page template, composed with a layout: ```html / main

Counter: 0

``` **`main.html`** — the layout template: ```html main My App

My App

``` **`HomeEntrypoint.vala`** — hydrates the page: ```vala public class CounterPublic : Object { public int value { get; set; } public Model.ActionDto bump { get; set; } } public class HomeEntrypoint : StatumEntrypoint { public override async DirectiveBuilder handle() throws GLib.Error { var counter = new CounterPublic(); counter.value = 0; counter.bump = action_registry.author_private(new BumpPrivate()); return directives().set_typed("counter", Scope.PAGE, counter); } } ``` **`Main.vala`** — wires it all up: ```vala void main(string[] args) { var app = new WebApplication(8080); app.add_module(); var statum = app.configure_with(); statum.add_page(); // route from statum.action(); // GUID auto-assigned app.run(); } ``` ### 2. A guarded page ```html /dashboard main ``` The guard redirects unauthenticated visitors to `/` before the entrypoint loads. --- ## HTML Templates Statum pages are HTML files processed at build time by `statum-mkpstm`. ### Build-time directives (`pstm-*`) All `pstm-*` elements **must appear inside ``** (except `pstm-content` and `pstm-fragment`, which live in ``). | Directive | Where | Purpose | |---|---|---| | `/path` | head | The page's route. **Required.** Supports templates: `/cats/{species}`. | | `main` | head | Wraps this page in the named template (by ``). | | `main` | head | Names this file so pages/fragments can reference it. | | `…` | head | Page title, merged up the template chain. | | `` | body (template) | The outlet where the child page's body is inserted. | | `` | body | Inlines a fragment (component) by name. | | `pstm-materialise-as="div"` | `` attr | When this body is composited into a template or fragment, it is wrapped in the named element. | | `pstm-res-src="logo.png"` | any attr | Rewrites to `src="/_statum/resource/logo.png"`. Also `pstm-res-href`, etc. | | `pstm-res-src="statum.js"` | ` ``` You may cherry-pick trailing modules (core first, always): core + document gives the full state-binding walk without the markdown engine — but note `stm-markdown` and `stm-h-demotion` live in the markdown module and do nothing without it. Core ALONE is headless: it never touches the DOM, so a non-document templating framework can use it as its state layer (write your endpoint URLs into `statum.config` before `statum.init()` runs and consume `statum.addStateListener`). A legacy `` still works: `statum-mkpstm` expands it at build time into the four tags above (one build warning per page names the expansion; a page mixing the legacy reference with explicit module tags is deduplicated). Prefer writing the four tags explicitly in new pages. The client auto-initialises on DOMContentLoaded. ### Configuration overrides (body attributes) ```html ``` ### HTML binding attributes (`stm-*`) | Attribute | Description | |---|---| | `stm-text="counter.value"` | Sets the element's text content from the expression. | | `stm-html="cart.summary"` | Sets the element's inner HTML. | | `stm-markdown="post.body"` | Renders the value as CommonMark (all source HTML escaped first). | | `stm-h-demotion="1"` | Demotes child `

`–`

` tags by N levels. Runs after `stm-html`/`stm-markdown`. | | `stm-if="auth != null"` | Shows/hides the element (and its `stm-else-if`/`stm-else` siblings). | | `stm-else-if="auth.role == 'admin'"` | Next sibling of `stm-if`/`stm-else-if`. | | `stm-else` | Next sibling of `stm-if`/`stm-else-if`. | | `stm-class.active="cart.count > 0"` | Toggles the class `active` based on the predicate. | | `stm-disabled="!user.canSubmit"` | Disables the element + descendants when truthy. | | `stm-action="cart.checkout"` | Invokes the action reference resolved from state. On a `
`, collects form data; on a button, fires on click. | | `stm-data-key="value"` | Provides data to the enclosing `stm-action`. | `stm-markdown` targets the CommonMark 0.31.2 specification: paragraphs break only on blank lines (a single newline is a soft break, not a new paragraph), emphasis may span a line wrap, and a wrapped line stays inside its list item. ```html
``` With `post.body` set to: ```markdown **bold text that wraps** across lines - a list item whose text continues on the next line - another item ``` the element renders as one `` spanning both lines, a single paragraph, and a two-item list whose first item contains both of its lines. ### Guards ```html ``` Evaluated on every render cycle; `pre-entrypoint` also evaluates before the entrypoint fetch. On failure, the page redirects to `href`. ### Boot states ```html
Loading…
Page content here.
``` ### Public JS API ```js // Read current state var cart = statum.state("cart"); // → { value: 0, bump: {…} } // Listen for changes statum.on("cart", function(data, old) { console.log("cart changed", data); }); // Perform an action programmatically statum.act("cart.checkout", { qty: 2 }) .then(function(directives) { … }) .catch(function(err) { … }); // Hooks: no-arg callbacks before/after each render cycle statum.beforeEvaluate(function() { document.body.classList.add('evaluating'); }); statum.afterEvaluate(function() { highlightCodeBlocks(); }); ``` ### Running the markdown tests The `stm-markdown` renderer (`js/statum-markdown.js`) has a JavaScript test suite that does not require Node.js — it runs under [QuickJS](https://bellard.org/quickjs/), whose sources are vendored in `tools/qjs/` and compiled on demand by `gcc`: ```sh tests/markdown/run.sh # regressions + CommonMark spec suite tests/markdown/run.sh -v # also list every failing spec example ``` The run reports the conformance rate against the official CommonMark 0.31.2 example suite (excluding the raw-HTML sections, which Statum escapes by design) and fails when it drops below the 95% target. See `tests/markdown/README.md` for details. ### Running the module-split and DOM-shim tests Two more QuickJS suites cover the split client itself: ```sh tests/split/run.sh # modules load in order and register into the seams tests/dom/run.sh # the document walk + stm-prefill dirty/skip/reset ``` `tests/split` proves each module boots, registers its attributes through the core seam in the canonical order, and that cherry-picked subsets work. `tests/dom` runs the render walk and the form bindings against a minimal DOM shim (value/checked writes, radio groups, dirty skipping, clear-on-success). --- ## Vala API Reference ### `StatumModule` Registers the framework services and endpoints. Add it to your application: ```vala app.add_module(); ``` ### `StatumConfigurator` Binds pages, actions, and resources: ```vala var statum = app.configure_with(); statum.add_page(); // page + entrypoint statum.add_page(); statum.add_static_page(); // page, no entrypoint statum.action(); // GUID action statum.action(); statum.add_resource(); // CSS/JS/image resource ``` Routes are derived from `` (not passed manually). A page without a `` fails at startup. ### `DirectiveBuilder` Started via `directives()` on any handler. Fluent, self-returning: | Method | Description | |---|---| | `.set(Slot)` | Signs a fresh slot → `set` directive. | | `.set_typed(type, scope, model)` | Creates + signs from a typed public model. | | `.set_private_typed(type, scope, pub, priv)` | Creates + signs from typed public + private models. | | `.update(key, State)` | Mutates + pushes to channels + signs → `set`. | | `.update_held(type, c => …)` | Typed read-mutate-write of a held slot (preserves action refs). | | `.clear(key)` | `clear` directive. | | `.navigate(uri)` | Terminal: redirect the client. | | `.form_submit(form_id, action?, method?)` | Terminal: submit a form by ID (optionally override action + method). | | `.form_reset(form_id)` | Terminal: reset a form by ID. | | `.notify(kind, message)` | Surface a notification (`kind` is app-defined). | | `.error(message, code?, data?)` | Report an error to the client. | | `.subscribe(key)` | Subscribe to realtime updates for a slot. | | `.set_global(type)` | Signs and emits `set` for a global broadcast slot. | | `.subscribe_global(type)` | Subscribes to a global broadcast slot's live updates. | | `.unsubscribe(key)` | Unsubscribe. | | `.set_frame(FrameDto)` | `set` from an already-signed frame. | | `.invalidate(signature)` | `invalidate` (drop a bad frame). The `reenter` directive (re-fetch the page's entrypoint in place, without a page reload) has no builder method: return it via the DTO itself, `new Model.ReenterDirectiveDto()`, like any directive object. See [model.md](model.md) for its payload-free wire form and client ordering. | ### `StatumRequest` | Member | Type | Description | |---|---|---| | `uri` | `string` | The request URI. | | `route_params` | `ReadOnlyAssociative` | Route params from `` templates. | | `held` | `ReadOnlyAssociative` | Held slots by type name. | | `query` | `Catalogue` | Query parameters. | | `action_private` | `Properties?` | Decrypted action private (untyped). | | `form` | `FormData?` | Parsed form body. | | `held_as(type)` | `T?` | Typed read of a held slot's public data. | | `route(name)` | `string?` | Raw route parameter. | ### `StateService` | Method | Description | |---|---| | `new_slot(scope, state)` | Creates + caches a slot, returns it. | | `get_slot(key)` | Reads a cached slot (pure, no mutation). | | `sign_slot(key)` | Signs a slot's current state → `FrameDto`. | | `update(key, state)` async | Mutates, signs, pushes to channels → `FrameDto`. | | `clear_slot(key)` async | Removes + pushes `clear` event. | | `restore_slot(frame)` | Re-caches a slot from a verified frame. | | `new_global_slot(type, scope, state)` | Creates a global slot with deterministic key `"global:" + type`. | | `update_global(type, state)` async | Updates a global slot (sign + push to all subscribers). | | `update_global_typed(type, mutator)` async | Typed read-mutate-write of a global slot. | | `clear_global(type)` async | Clears a global slot + pushes `clear`. | ### `ActionRegistry` | Method | Description | |---|---| | `author(method)` | Action reference (no private). | | `author_private(model, method)` | Action reference with sealed typed private. | ### `Scope` enum `PAGE`, `FLOW`, `SESSION`, `DEVICE`, `UNIDIRECTIONAL`. ### `GObjectMapping` Maps GObject models to/from `Properties` via introspection (no per-model mapper). ActionDto fields round-trip as nested JSON via the mapper registry; scalars (incl. `int64`/`bool`) and `string[]` round-trip, and collection properties serialise as arrays — see [Typed model serialization](#typed-model-serialization). | Method | Description | |---|---| | `to_properties(Object)` | Model → `Properties`. | | `from_properties(Type, Properties)` | `Properties` → model (`Object`). | | `from_properties_typed(Type, Properties)` | Convenience: returns `T`. | | `register_mapper(mapper)` | Register a nested-object mapper. | | `get_string/get_int/get_bool/get_double(props, key)` | Typed ad-hoc getters. | ### Static keys (`web-config.json`) To make signed frames and encrypted private blobs survive a server restart, configure static keys: ```bash statum-genkeys > web-config.json ``` ```json { "statum": { "signing_secret_key": "…", "signing_public_key": "…", "encryption_signing_secret_key": "…", "encryption_signing_public_key": "…", "encryption_sealing_secret_key": "…", "encryption_sealing_public_key": "…" } } ``` Without these, the server generates random keys per restart (fine for development; a warning is logged). ### Topic system The topic system lets external services (workers, webhooks, timers) push updates to clients watching a specific entity. A **topic** loads the entity from the database when triggered; **state modifiers** derive specific slot types from the payload and push them to subscribed clients via SSE. ```vala // A typed topic: loads the entity once per trigger public class CatTopic : Topic { private Database db = Inversion.inject(); public override async Cat load(string topic_key) throws GLib.Error { return yield db.load_cat(int.parse(topic_key.split(":")[1])); } } // A state modifier: derives a slot type from the payload + held context public class CatDetailModifier : StateModifier { public override CatDetail derive(Cat cat, CatDetail current, HeldStates held) { if (current != null && current.breed != cat.breed) return current; // skip return new CatDetail() { breed = cat.breed, description = cat.description }; } } // Registration statum.topic("cat"); statum.topic_modifier("cat"); // Entrypoint: watch a topic var slot = state_service.new_slot(Scope.PAGE, state); topics.watch("cat:42", slot.id, typeof(CatDetail).name(), request.held); return directives().set(slot).subscribe(slot.id); // Trigger from anywhere (worker, webhook, timer) yield topic_registry.trigger("cat:42"); ``` `derive` return values: new instance → push via SSE; `current` (same ref) → skip; `null` → clear slot + remove watcher. Exceptions are treated as `null`. `HeldStates` provides a live lookup of the client's held slots at trigger time (reads current cache values): ```vala var auth = held.get("auth"); // null if expired ``` | Component | Description | |---|---| | `Topic` | Base class. Override `load(topic_key)` to produce the payload. | | `StateModifier` | Base class. Override `derive(payload, current, held)`. | | `HeldStates` | Live lookup of client-held state by type name. | | `TopicRegistry` | `watch(topic, slot_key, slot_type, request_held)`, `trigger(topic)`, `sweep()`. | | `statum.topic(prefix)` | Registers a topic handler. | | `statum.topic_modifier(prefix)` | Registers a state modifier. |