- scaffold 14 placeholder crates (app, db, engine, gen, http, logic,
policy, ql, service, sub, txn, ui, value, wal) — empty Cargo.toml +
src/lib.rs to receive code phase-by-phase from `rt`
- restructure docs/examples/ecommerce into multi-app layout: apps/admin
and apps/storefront, with shared/ types/logic/components, per-app
app.wo + wo.toml, and reusable .htmlx components (layout, money,
order-row)
- add reference/rest/{blog,ecommerce}.rest — VS Code/JetBrains HTTP
request files driving the running prototype, including 501/404/405
expectations for stubbed endpoints
- add docs/plan/09-concurrency-scaleout.md and docs/plan/ui/00-overview.md;
refine docs/plan/assembly/02-writeonce-stance.md
- refresh templates (about, article, header/footer, home, layout, styles)
and add static favicon/logo
- add infra/sync.sh and tighten .gitignore for reference/ symlinks
18 KiB
UI track — .htmlx live templates + Angular-style monorepo
Context sources: docs/examples/ecommerce/ui/ (current ##ui screens — storefront, order_tracker, admin_orders), docs/examples/ecommerce/types/ + docs/examples/ecommerce/logic/ (the shared-schema + shared-fn anchor), reference/crates/wo-htmlx/ (v1 template engine — {{path}}, {{#each}}, {{> partial}}, data-bind attributes), templates/ (v1 blog's concrete .htmlx usage), docs/runtime/database/06-lowcode-fullstack.md (Phase 6's ##ui + ##app block spec).
Context
Three threads converge into one plan:
-
##uineeds a concrete output format. Phase 6's spec says screens "compile to a render tree" served as SSR HTML with a thin client runtime, but the actual template format isn't named. The v1.htmlxengine atreference/crates/wo-htmlx/already speaks{{bindings}},{{#each}},{{> partials}}, anddata-bindattributes — it's 90% of what the new runtime needs and already has a working parser + renderer. Adopting it (and extending it with live-subscription semantics) is cheaper than inventing a new format. -
The samples want a home that matches how real frontends are organised. The ecommerce sample today is one flat directory with
types/,logic/, andui/beside each other. A real deployment has multiple apps against the same data: a customer storefront, an admin dashboard, a fulfillment console, maybe a read-only analytics viewer. Each has its own routes, its own policies, its own ideal binary shape. Angular (via Nx / Angular CLI workspaces) solved this withapps/*+libs/*on top of a shared root config — writeonce adopts the same shape. -
Each app wants to be its own binary but share a database. Running storefront and admin as one monolith conflates concerns: a CPU spike in admin stalls customer checkout; an admin auth bug opens customer data paths. Splitting into per-app binaries that share a single database backend (via the Phase-4 native wire protocol) gives blast-radius isolation without duplicating data.
Intended outcome: docs/examples/ecommerce/ refactors into a workspace with shared/ + apps/storefront/ + apps/admin/. wo build apps/storefront produces a storefront binary. wo db serve runs the shared database. The apps connect over wo://… and serve .htmlx SSR pages that subscribe to LIVE queries without a page reload.
Goal
After this track's sub-phases land:
- A monorepo workspace at
docs/examples/ecommerce/withshared/{types,logic,components}+apps/{storefront,admin}structure. - A per-app binary for each app under
apps/:wo build apps/storefront→./target/wo/storefront,wo build apps/admin→./target/wo/admin. Each binary includes only its own##ui/##app/ app-local types and logic; shared code compiles in by path reference. - A shared DB daemon (
wo db serve) — one process, no UI, just the engine and wire-protocol server. Each app binary connects as a client via the Phase-4 native protocol. .htmlxas the compiled UI output. Every##uiscreen compiles into an.htmlxtemplate file that the app binary serves; hand-written.htmlxfiles underapps/X/ui/*.htmlxare accepted as a first-class authoring alternative..htmlxsubscribes. A<wo:live source="...">subtree in the template registers a LIVE query at page load; a ~20 KB client JS runtime patches DOM nodes on delta frames without reloading the page.- Per-app users + policies. Each app declares its role set in
apps/X/app.wo; row-level policies on shared types stay global, app-scoped policies layer on top per route.
Design decisions (locked)
.htmlxis the template format;##uiis the DSL that emits it. Authors choose per-screen: declare##ui #home { source: Product, columns: [...] }in.woand let the compiler producehome.htmlx; OR hand-writehome.htmlxfor a custom page. Both flow through the samewo-htmlxrenderer.- Extend v1
.htmlxwith two new constructs —<wo:live source="..." key="...">...</wo:live>(subscription subtree) andwo:bind="field"(field-level live binding). The rest of the v1 syntax ({{path}},{{#each}},{{> partial}}) carries through unchanged. - One binary per app, shared database process. Not a shared library, not a monolith. Apps connect via the Phase-4 wire protocol (
wo://host:port) — the same connection a Go/TS client would use. No in-process shared state between apps; their isolation is enforced by the OS process boundary. - Angular-parallel workspace layout.
apps/*for deployable binaries,shared/*for libs shared across apps (types, logic, UI components),wo.tomlat the workspace root. Each app also has its ownwo.tomlthat names whichshared/directories it depends on. - File structure mirrors Angular component organisation. Each UI screen lives in its own directory:
apps/storefront/ui/home/{home.wo, home.htmlx, home.css}. Tests go inhome.test.wo. Matches the Angular component pattern (home.component.ts,home.component.html,home.component.scss). - Per-app routes, not per-screen routes.
apps/storefront/app.wodeclares route table; each route maps to aui.<screen>declared underapps/storefront/ui/*/. Cross-app navigation is an external redirect, not an internal route. - Policy composition. Global policies live in
shared/types/<type>.wonext to thetypedeclaration (today). App-scoped policies live inapps/X/app.woand AND with the global set — an admin app might relax a storefront policy for ops roles but can never relax beyond what the type's own policy permits.
Angular parallels — what writeonce copies, what it doesn't
| Angular feature | Writeonce translation | Notes |
|---|---|---|
nx workspace / angular.json |
Root wo.toml with [workspace] apps = [...], shared = [...] |
Path references, not package registry |
apps/<app>/ |
apps/<app>/ with app.wo + ui/ + local types/ + local logic/ |
1:1 naming |
libs/<lib>/ |
shared/<lib>/ |
Used shared/ instead of libs/ — matches the more common monorepo idiom (Nx defaults to libs, but shared is clearer for this audience) |
<component>.ts + .html + .scss |
<screen>.wo + .htmlx + .css under ui/<screen>/ |
One-directory-per-screen |
ng build <app> |
wo build apps/<app> |
Per-app binary output |
| Dependency injection | Service-block resolution — service rest blocks in shared types are callable from any app by import |
No runtime DI container; bindings are resolved at compile time |
| RxJS observables | LIVE subscription frames on a WebSocket | Declarative live attribute instead of imperative .subscribe(...) |
| Zone.js change detection | Per-row delta dispatch + field-level wo:bind |
No full-tree change detection — only the rows/fields the delta names get repainted |
HttpClient |
Built-in wire-protocol client inside the app binary | No separate library to import; always present |
What we don't copy
- No TypeScript. Authoring is
.wo(for logic) +.htmlx(for templates) +.css. If a page needs bespoke JS interactivity beyond whatwo:bindcovers, a<script>tag inside.htmlxis fine — but the client runtime itself is vanilla JS, not a framework. - No component library split. Angular's
@angular/core,@angular/common, etc. are a package hierarchy. Writeonce's runtime is one binary; "components" are just shared.htmlxpartials undershared/components/. - No decorator metadata / reflect-metadata. Rust macros + compile-time codegen do the same work.
Reference materials
Read before writing each sub-phase:
| Source | Why |
|---|---|
reference/crates/wo-htmlx/src/parser.rs + render.rs |
The v1 template engine's exact surface — what parses, what renders, what the AST looks like. ~500 LOC total. |
templates/article.htmlx, templates/home.htmlx |
Concrete usage of the v1 format — how {{path}} and data-bind actually read in real templates. |
docs/examples/ecommerce/ui/{storefront,order_tracker,admin_orders}.wo |
The ##ui side — what the declarative DSL promises to produce. These screens are the target of the first compiler pass. |
docs/runtime/database/06-lowcode-fullstack.md |
Phase 6's full-stack block spec — ##ui, ##app, ##policy, ##service, ##logic — already designed but not yet compiled. |
docs/runtime/database/04-client-api.md |
Phase 4's wire protocol — what the per-app binary speaks to the shared DB over. |
| Nx monorepo docs | Background on the apps/libs split pattern; shape of nx.json. |
Target layout
docs/examples/ecommerce/
├── wo.toml # workspace — lists apps, names the shared DB port
├── shared/ # imported by any apps that need it
│ ├── types/
│ │ ├── customer.wo # Customer + role union + policy read/write
│ │ ├── product.wo # Product + inventory + similar_to graph
│ │ ├── order.wo # Order + line_items + lifecycle triggers
│ │ └── purchase.wo # link Customer -> Product
│ ├── logic/
│ │ └── checkout.wo # fn checkout / mark_paid / mark_shipped
│ └── components/ # reusable .htmlx partials
│ ├── layout.htmlx # top-level page chrome
│ ├── header.htmlx
│ ├── money.htmlx # {{> money amount=x}} → $x.xx
│ └── order-row.htmlx # used by both storefront and admin
├── apps/
│ ├── storefront/ # customer-facing; no admin routes
│ │ ├── wo.toml # declares `shared = ["../shared"]`
│ │ ├── app.wo # routes: / → home, /product/:sku → product-detail
│ │ ├── logic/
│ │ │ └── cart.wo # app-local: fn add_to_cart, fn remove_from_cart
│ │ ├── types/
│ │ │ └── cart.wo # type Cart { lines: [CartLine], ... } — not shared
│ │ └── ui/
│ │ ├── home/
│ │ │ ├── home.wo # ##ui #home — declarative spec
│ │ │ ├── home.htmlx # optional hand-written override
│ │ │ └── home.css
│ │ └── product-detail/
│ │ └── product-detail.wo
│ └── admin/
│ ├── wo.toml
│ ├── app.wo # routes: /orders → orders, /inventory → inventory; gated role Admin|Ops
│ ├── logic/
│ │ └── fulfillment.wo # app-local: fn ship_order calls shared.mark_shipped
│ └── ui/
│ ├── orders/
│ │ ├── orders.wo # ##ui #admin-orders, live: true
│ │ └── orders.htmlx # hand-tuned layout overrides the auto-generated
│ └── inventory/
│ └── inventory.wo
└── tests/ # workspace-level integration
└── cross-app.test.wo # a checkout from storefront visible in admin live feed
Compile outputs:
target/wo/
├── storefront # ~12 MB static binary — app.wo compiled + ui/ templates baked in
├── admin # ~12 MB static binary
└── db # the `wo db` server (shared by all apps)
.htmlx with live subscriptions — target format
v1 carries forward unchanged:
<h1>{{article.title}}</h1>
<ul>
{{#each articles}}
<li><a href="/article/{{slug}}">{{title}}</a></li>
{{/each}}
</ul>
{{> layout.footer}}
Two new constructs for live:
<!-- Subtree bound to a LIVE query; client subscribes at page load -->
<wo:live source="Order{ status != Cancelled }" sort="placed_at desc" key="id">
<table class="orders">
<thead><tr><th>#</th><th>Status</th><th>Customer</th><th>Total</th></tr></thead>
<tbody>
{{#each rows}}
<tr data-key="{{id}}">
<td>{{id}}</td>
<td wo:bind="status" class="status-{{status}}">{{status}}</td>
<td>{{customer.name}}</td>
<td>{{> money amount=total}}</td>
</tr>
{{/each}}
</tbody>
</table>
</wo:live>
Semantics:
<wo:live source="...">emits aLIVE <source>query registration at SSR time. The initial result renders the{{#each rows}}body.- The compiler also emits a JSON manifest (in a
<script data-wo-manifest>tag) telling the client runtime which DOM id maps to which row key and what fields arewo:bind-ed. - On page load, the client runtime opens a WebSocket back to the app, subscribes, and processes delta frames:
Insertappends a row,Updatefinds[data-key="<id>"]and replaceswo:bind-ed cells,Deleteremoves the row. wo:bind="field"on any element tells the runtime "this element's text content reflectsrow.field"; delta Updates patch it in place.
Sub-phase sequence
Each lands as its own plan doc under docs/plan/ui/. No code yet — this master plan outlines the order.
| # | File | Goal |
|---|---|---|
01 |
01-htmlx-format-spec.md |
Nail down the exact .htmlx grammar — everything v1 has plus <wo:live> and wo:bind. Includes a manifest-emission spec so the client knows what to subscribe to. |
02 |
02-ui-compiler.md |
##ui → .htmlx compiler. Walks the parsed Phase-6 block and emits the template with the right <wo:live> / {{#each}} / wo:bind skeleton. Falls back gracefully when a hand-written .htmlx exists beside the .wo. |
03 |
03-client-runtime.md |
~20 KB vanilla-JS runtime bundled with the app binary. Parses <script data-wo-manifest>, opens WebSocket, handles snapshot/insert/update/delete frames, patches DOM by data-key + wo:bind. |
04 |
04-workspace-layout.md |
Concrete refactor of docs/examples/ecommerce/ from the current flat shape into shared/ + apps/*. Defines wo.toml workspace grammar. |
05 |
05-per-app-binaries.md |
wo build apps/X produces one static binary per app. Each contains its own types/logic/ui + the shared dirs it imports. Shared DB connection is configured via WO_DB env var. |
06 |
06-shared-db-daemon.md |
wo db serve — headless database daemon. Per-app authn (API key per app), per-app connection scope. Apps see only types their policy allows. |
07 |
07-per-app-policies.md |
App-scope policy composition — global policy read ... on a type AND app-local policy in app.wo ⇒ effective policy = AND of both. Admin app's relaxations, storefront's restrictions. |
Verification
After all seven sub-phases land:
| Target | Measure |
|---|---|
wo build apps/storefront succeeds, produces one static binary |
file target/wo/storefront → ELF, ldd shows only libc |
wo build apps/admin succeeds |
same |
wo db serve + storefront --db wo://localhost:5555 + admin --db wo://localhost:5555 all run simultaneously |
three processes, three ports, one data directory |
| Admin live orders table updates within 100 ms of a checkout on the storefront | Open /admin/orders in a browser, fire POST /api/fn/checkout against storefront's wire port, observe DOM patch |
| Customer's Order visible in their storefront order tracker but not to other customers; admin sees all | Policy round-trip |
wo run apps/storefront serves hand-written home.htmlx if present, falls back to ##ui #home generation if not |
File-presence-based dispatch |
curl http://localhost:8080/healthz from each app process |
200 ok — standard liveness across the tracks |
Non-scope
- No TypeScript, no JSX.
.htmlxis HTML + Mustache + twowo:tags. The client runtime is 500 lines of vanilla JS. - No build-time Angular-style bundling. No Webpack, no esbuild, no tree-shaking. The client JS is a pre-compiled static artifact inside each app binary.
- No hot module reload in production.
wo devreloads in development (inotify watchesapps/*/ui/); production binaries don't self-reload. - No cross-app shared session state. Each app authenticates independently. Shared identity is the customer row in the shared DB — both apps see the same user, but each app issues its own session token.
- No dynamic shared-library linking between apps. Sharing happens at source level (
shared/dirs imported by path). Each binary is a fully-static blob. - No React/Vue compatibility layer. If a downstream app wants those, they sit outside the writeonce runtime and talk to the shared DB over the wire protocol — same as any other client.
Cross-references
../09-concurrency-scaleout.md— when the shared DB daemon needs to handle 10k connections across multiple apps, that plan's thread-per-core model applies to the daemon process.../assembly/02-writeonce-stance.md— still no asm. The client runtime is vanilla JS, no WASM.../../runtime/database/06-lowcode-fullstack.md— Phase 6's full-stack block spec that this track implements.../../runtime/database/04-client-api.md— the wire protocol per-app binaries speak to the shared DB over.../../examples/ecommerce/ui/admin_orders.wo— the motivating workload: a live ops table bound to the order stream.reference/crates/wo-htmlx/— the template engine ~90% of this track will reuse.templates/— v1 blog's actual.htmlxfiles; the format this track extends.