- story 02: `status: done`, `review_pending` (forks 1–7 auto-approved for
autonomy); progress rows 6a ✅, 6b ➡ databasev2 5 Phase A, 7 `a310496`;
5c/5d rows cite the `dev` hashes (the pre-merge ones were unreachable);
task 6a's Given/When/Then met; Info records the seven forks (sentinel over
`:memory:`, its rules, the refusal contract, startup-only, the budget
leaves for 5, library-owned tables bind consumers, the v8 table bit);
History keeps the first cut that refused every class-bearing program
- database/src/CODE-LOGIC.md: "Startup refusal + WO_EPHEMERAL" — contract,
hatch, table bit, measured blast radius, deferred items, proof; the
dispatcher paragraph no longer says a failed commit un-applies the row
(fatal since databasev2 4 part A; WO_T_IO unreachable from a write path)
- residency spec + plan: task 6 items annotated with the 2026-09-09
decisions; the byte budget marked moved to databasev2 5
- README, seven example READMEs and four guides carry the one-line rule
(durable default refuses without WO_DATA; WO_EPHEMERAL=1; durable:
false); shop's RAM-only command sets the sentinel
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 2c3531998124042fe736388e8b926abda3841194)
7.1 KiB
shop — the writeonce program template
A small store you can buy from, structured the way a real writeonce web
app should be. Copy this directory to start a new app; every file
has one concern, and the module system (one directory = one module,
pub = the export line) enforces the separation the layout promises.
Run it
cd docs/examples/shop
woc . && WO_DATA=./data ./target/shop 8080 # durable store
WO_EPHEMERAL=1 ./target/shop 8080 # RAM-only (dev)
A program with any durable table (the default) refuses to start without WO_DATA; WO_EPHEMERAL=1 opts into a RAM-only run, @table(durable: false) opts a table out.
Browse http://127.0.0.1:8080/ — products → product page → buy (stock
checked and decremented) → confirmation → /orders. With WO_DATA, kill
it and restart: the orders are still there (WAL replay).
The template builds and runs as written — the two [deps] resolve, the
seed lands, and every route answers.
The view form
A render() body is one backtick raw text literal. Markup is markup:
real newlines, real double-quoted attributes, and the method's source
indentation removed at compile time, so the served bytes carry the
markup's own nesting and not the code's.
fn render() -> Text {
return `
<div class="card">
<h3><a href="/p/{{ self.sku }}">{{ self.name }}</a></h3>
<p class="price">€ ${self.price}</p>
${stock}
</div>`;
}
Every render() makes its class a component — writeonce-view's structural
Component interface, satisfied by having the method, never declared.
Parent components hold children directly (cards: multi Component) and
render them with render_all, so ProductListPage knows nothing about
ProductCard beyond render(). The document itself is a component too:
AppShell { title, content } in layout/app.wo, which links a real
stylesheet rather than inlining one — that is why it is its own shell and
not writeonce-view's Layout.
Two holes, and the difference is the whole escaping story:
{{ expr }}HTML-escapes — it compiles to a call to theescin scope (writeonce-view's, unless the app declares its own). Display data goes here; a typo'd field is a compile error, not a broken page.${ expr }is raw — for markup you built yourself, like the${stock}fragment above or${content}in the app shell.
Nothing is parsed at request time. The literal is a compile-time form: it produces exactly the string constant and concatenation chain the old hand-written version did, so there is no template engine to ship, warm up, or sandbox.
The file map (Angular equivalents)
| this template | concern | Angular analog |
|---|---|---|
types.wo |
MODEL — @table classes ARE the WAL database |
models/*.ts (+ the entire database) |
layout/app.wo |
app shell: document, header+footer composition, ok_html/html_error transport helpers |
app.component.html |
layout/header.wo / footer.wo |
shared chrome fragments | header.html / footer.html |
product_list/view.wo |
VIEW — classes with fn render() -> Text, fields = exactly what is displayed |
product-list/view.html |
product_list/controller.wo |
CONTROLLER — query the model, fill the view, answer a Resp; beside its view in the same module |
component .ts + service |
product_page/, orders/ |
one module per feature: view.wo + controller.wo |
feature folders |
/assets/* |
served by the framework's StaticFiles — the template no longer carries its own copy |
angular.json assets |
assets/style.css |
ONE real stylesheet, sectioned per feature | the .scss files |
the render() bodies |
ONE raw text literal each: real newlines, real double-quoted attributes, source indentation removed at compile time, ${} raw holes and {{ }} auto-escaping ones |
Vue <template> (in-SFC) |
layout/app.wo → AppShell |
the document, as a two-slot component | app.component.html |
main.wo |
bootstrap: seed, routes, serve — nothing else | app-routing.module.ts + main.ts |
Separation is compiler-enforced: one feature = one directory = one
module, holding that feature's view AND its controller. A module sees
only its own declarations plus what it uses, so a controller reaching
into another feature has to say so. The @table classes sit in
types.wo at the root and are reachable from every feature module
without export — see gap #1 below for why that is not the contradiction
it looks like.
What is deliberately different (doctrine)
- Templates compile or they don't exist (story 37). See The view form above: the markup is a compile-time literal, never a file parsed per request. Gap #3 ("the language has NO multi-line expression or literal", recorded while writing this template) is CLOSED — the raw literal landed with story 37's compiler slice, and this directory was its consumer. Styles stay a real CSS file, served statically (there is no scss preprocessor).
- No closures, no DI. A view is a class with fields +
render()(writeonce-view'sComponent); a controller is a class satisfyingHandler. Capture = a field. - The MVC seam is enforced by where the query sits. Controllers hold
every
from … select; a view receives VALUES — for the list page, amulti Componentof already-filled cards. No view in this template touches the database, and writeonce-view contains no query at all. - No sessions/cart yet. Buying is per-product (qty → order). A cart needs a session story that does not exist yet.
- No client-side JS. Every interaction is a form round trip.
pub+@tablecannot combine (recorded gap #1) — and it does not matter.pubin front of an annotated class is a parse error (WO-E101), but no@tableneeds it: a CLASS is reachable across module lines without being exported. Verified 2026-08-25 by building and running all three shapes — a feature module querying a root@table, a feature module using a root plain class, and an@tabledeclared inside atypes/module and queried from the root. What IS module-scoped is a freefn(WO-E210), so a query shared by two features belongs on a class as astatic fn. An earlier revision of this file claimed the gap forced controllers into the root module and made atypes/module impossible; both were wrong, and the layout below is what the language actually allows.@viewprojection classes (recorded gap #2): today controllers copy row fields into view classes by hand. The wished-for form —class ProductCard @view { ... }filled byfrom p in Product select p.name, p.price— needs projection queries; recorded, not worked around.
Judging the DX — what to look at
types.wo— the entire persistence layer is 20 lines.orders.controller.wo— the whole buying flow (validate, stock check, decrement, durable insert, render) with no framework magic.orders/view.wo— the referendum, now answered: three render() bodies, each one literal, no concatenation and noesc()calls, andOrdersPageholding its rows as child components.main.wo— the app at a glance: five routes, one middleware, serve.