adding a app-web-UI prototype

This commit is contained in:
shoney.arickathil 2026-05-04 22:56:28 +02:00
parent 9279175f26
commit 21b3f1d894
16 changed files with 961 additions and 10 deletions

View file

@ -27,15 +27,44 @@ blog/
│ ├── article.wo # Article — all three paradigms in one type
│ ├── tag.wo # Tag taxonomy
│ └── comment.wo # Reader comments
├── styles/
│ ├── main.css # global stylesheet (linked from app.wo styles:)
│ └── code-theme.css # syntax highlighting tokens
├── ui/
│ ├── article_list.wo # home page list view (live)
│ └── article_detail.wo # per-article page with comments + related
│ ├── article_detail.wo # per-article page with comments + related
│ └── components/ # reusable components (.wo + .htmlx + .css per component)
│ ├── article-card.wo # selector + typed inputs + styles:
│ ├── article-card.htmlx
│ ├── article-card.css
│ ├── comments.wo # selector + source + actions + role + styles:
│ ├── comments.htmlx
│ └── comments.css
└── tests/
└── article_test.wo # `wo test` picks this up
```
No `main.wo` is needed — a pure type+service app auto-generates its entry point. Add `main.wo` if you need CLI args, background workers, or custom startup logic beyond the `on startup` hook in `app.wo`.
### UI: components vs. screens
The `ui/` tree separates concerns the way Angular separates `@Component` / template / parent:
- **Screens** (`ui/article_list.wo`, `ui/article_detail.wo`) declare a `##ui` block — they own the route, the page-level data source, and the section layout. They embed components by selector via `use: <name>` + `with: { ... }` and pass typed inputs.
- **Components** (`ui/components/*.wo`) declare a `##component` block with `template: 'foo.htmlx'`, typed `inputs:`, and — when the component owns its own query — its `source:`, `sort:`, `live:`, and `actions:`. No HTML.
- **Templates** (`ui/components/*.htmlx`) are pure presentation. They read from the component's `inputs` and from the rows produced by its `source`. No data-source declarations, no role checks.
Screens never inline a component's HTML or its query; templates never declare data sources. Each `.wo` paired with one `.htmlx` is the unit of UI reuse.
### Styling
CSS is declared at two scopes; in both cases the compiler emits the `<link>` tags into the SSR layout and serves the files under `/static/`:
- **App-level (global)** — `##app styles: [...]` in `app.wo` lists global stylesheets. Resolved relative to `./styles/`. Linked once, in declaration order, on every page.
- **Component-scoped** — `##component styles: [...]` lists CSS files alongside the component. The compiler rewrites bare selectors in those files to `[data-component="<selector>"] <rule>`, using the `data-component` attribute the templates already emit. Rules cannot leak outside the component subtree, so two components can both declare `.title` without colliding.
A component's CSS is only fetched on pages that embed the component. Global styles always load. Neither layer requires a build step — `wo run` serves the files as-is.
## Run it
```bash

View file

@ -8,6 +8,13 @@ version: 1
theme: "light"
i18n: [en]
-- Global stylesheets. Resolved relative to ./styles/, served under
-- /static/styles/, and emitted as <link> tags in the SSR layout in this
-- order. Component-scoped CSS lives next to the component (see ui/components).
styles:
- styles/main.css
- styles/code-theme.css
-- URL → UI screen binding. `:slug` is a dynamic path segment that binds to a
-- parameter visible inside the screen as `$slug`.
routes:

View file

@ -24,13 +24,10 @@
columns: [title, meta.excerpt, published_at]
live: true
-- Reader comments. Two-way live: new comments appear without refresh.
-- Reader comments. The article-comments component owns its own data
-- source, sort, live binding, and create action — this screen only
-- declares the embed and binds its typed inputs.
- comments:
title: "Comments"
renderer: list
source: Comment{ article.slug == $key }
columns: [author.display, body, created_at]
sort: { default: created_at asc }
live: true
actions:
create: /api/comments role: Author | Admin
use: article-comments
with:
article-id: $article.id

View file

@ -0,0 +1,15 @@
/* Scoped to the article-card component via [data-component="article-card"]. */
.article-card { padding: 1.25rem 0; border-bottom: 1px solid #eee; }
.article-card-title { margin: 0 0 0.25rem; font-size: 1.2rem; }
.article-card-title a { text-decoration: none; color: #222; }
.article-card-title a:hover { color: #0066cc; }
.article-card-meta { color: #999; font-size: 0.85rem; margin-bottom: 0.25rem; }
.article-card-author { color: #555; }
.article-card-excerpt { color: #555; margin-bottom: 0.5rem; }
.article-card-tags { list-style: none; padding: 0; display: flex; gap: 0.5rem; font-size: 0.8rem; }
.article-card-tags a { color: #888; text-decoration: none; }
.article-card-tags a:hover { color: #0066cc; }

View file

@ -0,0 +1,22 @@
<article class="article-card" data-component="article-card" data-id="{{article.id}}">
<h2 class="article-card-title">
<a href="/article/{{article.slug}}">{{article.title}}</a>
</h2>
<p class="article-card-meta">
<span class="article-card-author">{{article.author.display}}</span>
<time datetime="{{article.published_at}}">{{article.published_at}}</time>
</p>
{{#if article.meta.excerpt}}
<p class="article-card-excerpt">{{article.meta.excerpt}}</p>
{{/if}}
{{#if article.tags}}
<ul class="article-card-tags">
{{#each article.tags as tag}}
<li><a href="/tag/{{tag.slug}}">{{tag.name}}</a></li>
{{/each}}
</ul>
{{/if}}
</article>

View file

@ -0,0 +1,11 @@
-- The article-card component renders one article preview. It has no source of
-- its own — the parent (a list screen) iterates a query and hands each row in
-- via the typed `article` input. Pure presentation lives in article-card.htmlx.
##component
#article-card
template: 'article-card.htmlx'
styles: ['article-card.css']
inputs:
article: Article

View file

@ -0,0 +1,19 @@
/* Scoped to the article-comments component via the data-component attribute
* emitted by comments.htmlx. The compiler rewrites bare selectors below to
* [data-component="article-comments"] <selector>, so the rules cannot leak
* outside the component subtree. */
.comments-header h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: 1px; color: #888; }
.comment-list { list-style: none; padding: 0; margin: 1rem 0; }
.comment { padding: 0.75rem 0; border-bottom: 1px solid #eee; }
.comment:last-child { border-bottom: none; }
.comment-meta { display: flex; gap: 0.75rem; font-size: 0.85rem; color: #999; margin-bottom: 0.25rem; }
.comment-author { color: #555; font-weight: 600; }
.comment-body { margin: 0; }
.comment-form { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
.comment-form textarea { padding: 0.5rem; font-family: inherit; border: 1px solid #ddd; border-radius: 4px; resize: vertical; min-height: 4rem; }
.comment-form button { align-self: flex-start; padding: 0.4rem 1rem; background: #222; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.comment-form button:hover { background: #0066cc; }

View file

@ -0,0 +1,25 @@
<section class="comments" data-component="article-comments" data-article-id="{{article-id}}">
<header class="comments-header">
<h3>Comments</h3>
</header>
<ul class="comment-list" data-live="comments">
{{#each comments as comment}}
<li class="comment" data-id="{{comment.id}}">
<div class="comment-meta">
<span class="comment-author">{{comment.author.display}}</span>
<time datetime="{{comment.created_at}}">{{comment.created_at}}</time>
</div>
<p class="comment-body">{{comment.body}}</p>
</li>
{{/each}}
</ul>
{{#when actions.create}}
<form class="comment-form" data-action="create">
<input type="hidden" name="article" value="{{article-id}}">
<textarea name="body" required placeholder="Write a comment&hellip;"></textarea>
<button type="submit">Post comment</button>
</form>
{{/when}}
</section>

View file

@ -0,0 +1,23 @@
-- The article-comments component renders the comment thread for one article
-- and exposes an inline create action. Wiring lives here; presentation lives
-- in comments.htmlx; the parent screen (ui/article_detail.wo) embeds the
-- component by selector and binds its inputs.
##component
#article-comments
template: 'comments.htmlx'
styles: ['comments.css']
inputs:
article-id: int
source:
Comment{ article.id == $article-id }
sort:
default: created_at asc
live: true
actions:
create: /api/comments role: Author | Admin

View file

@ -0,0 +1,111 @@
# 01 — `.htmlx` format spec
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "`.htmlx` with live subscriptions — target format" (L127–166), "Design decisions" (L28–37), [`reference/crates/wo-htmlx/`](../../../reference/crates/wo-htmlx/) (the v1 template engine that 90% of this phase ports), [`templates/article.htmlx`](../../../templates/article.htmlx) and [`templates/home.htmlx`](../../../templates/home.htmlx) (v1 concrete usage), [`docs/examples/ecommerce/shared/components/order-row.htmlx`](../../examples/ecommerce/shared/components/order-row.htmlx) (the live-binding workload this format must serve).
## Goal
Lock the exact `.htmlx` grammar — every v1 Mustache construct unchanged plus two new constructs: a `<wo:live source=… key=…>…</wo:live>` subscription subtree and a `wo:bind="field"` field-level live attribute — and define the JSON manifest emitted in a `<script data-wo-manifest>` so the client runtime (phase 03) knows which DOM nodes map to which subscription rows and fields.
## Design decisions (locked)
1. **Mustache constructs carry through unchanged.** `{{path}}`, `{{#each xs as y}}…{{/each}}`, `{{#if cond}}…{{/if}}`, `{{#when cond}}…{{/when}}`, `{{> partial arg=val}}`. The v1 parser already handles all of these; the new parser inherits them verbatim. See [`reference/crates/wo-htmlx/src/parser.rs`](../../../reference/crates/wo-htmlx/src/parser.rs) (173 LOC) and the AST in [`ast.rs`](../../../reference/crates/wo-htmlx/src/ast.rs) (18 LOC).
2. **`<wo:live>` is a parsed structured node, not HTML passthrough.** The parser recognises the `<wo:` prefix, captures attributes (`source`, `key`, optional `sort`, `filter`), and recursively parses the body as a normal `.htmlx` subtree. No nesting in this phase — error at parse if a `<wo:live>` contains another `<wo:live>`.
3. **`wo:bind="field"` is an HTML attribute, parsed but emitted verbatim.** SSR writes the attribute through; the consumer is the client runtime. The parser records each `(element, field)` pair into the manifest; nothing else changes about element rendering.
4. **Helpers are a closed Rust enum.** v1 invocation forms (`{{relative ts}}`, `{{#if (eq for "ops")}}`, `{{> money amount=x}}`) carry through. The registered set is fixed for this phase: `relative`, `eq`, `markdown`, `code`, `money`, `tag-chips`, `pill`, `image`, `stock-badge`, `list`. No author extensibility.
5. **Manifest is one JSON object per page.** Emitted as `<script type="application/json" data-wo-manifest>{ … }</script>` and consumed only by phase 03's client runtime. Schema below; `version: 1` is a constant for this phase.
## Scope
### New files inside `crates/ui/src/htmlx/`
| File | Responsibility | Port source |
| --- | --- | --- |
| `mod.rs` | Re-exports `Template`, `Manifest`, `LiveSubscription`, `BindSite`, `ParseError`, `RenderError` | [`reference/crates/wo-htmlx/src/lib.rs`](../../../reference/crates/wo-htmlx/src/lib.rs) (11 LOC) |
| `ast.rs` | Adds `Node::Live { attrs, body }` and `wo_bind: Option<String>` on element nodes | [`reference/crates/wo-htmlx/src/ast.rs`](../../../reference/crates/wo-htmlx/src/ast.rs) (18 LOC) — extend by ~50 LOC |
| `parser.rs` | Adds `<wo:` prefix recognition + attribute capture; rest unchanged | [`reference/crates/wo-htmlx/src/parser.rs`](../../../reference/crates/wo-htmlx/src/parser.rs) (173 LOC) — extend by ~90 LOC |
| `value.rs` | Path resolution against a context Value | [`reference/crates/wo-htmlx/src/value.rs`](../../../reference/crates/wo-htmlx/src/value.rs) (122 LOC) — copied verbatim |
| `registry.rs` | Closed helper-fn registry | [`reference/crates/wo-htmlx/src/registry.rs`](../../../reference/crates/wo-htmlx/src/registry.rs) (121 LOC) — extend by ~60 LOC for new helpers |
| `render.rs` | Emits HTML; wraps `<wo:live>` body in `<div data-wo-subscription="…">` for the runtime | [`reference/crates/wo-htmlx/src/render.rs`](../../../reference/crates/wo-htmlx/src/render.rs) (140 LOC) — extend by ~70 LOC |
| `manifest.rs` | Walks the AST, collects subscriptions + bind sites, serialises JSON | new (~150 LOC) |
Total: ~835 LOC (585 ported + ~250 new).
### `Cargo.toml` change
```toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```
(Both already in `crates/rt`; `crates/ui` adopts them rather than hand-rolling JSON for the manifest at this phase.)
### Manifest schema
```json
{
"version": 1,
"subscriptions": [
{
"id": "orders-live-0",
"source": "Order{ status != Cancelled }",
"key": "id",
"sort": "placed_at desc",
"filter": null,
"root_selector": "[data-wo-subscription=\"orders-live-0\"]"
}
],
"bind_sites": [
{ "subscription_id": "orders-live-0", "key": "id", "field": "status" },
{ "subscription_id": "orders-live-0", "key": "id", "field": "total" }
]
}
```
## API shape (target)
```rust
use ui::htmlx::{Template, Manifest, HelperRegistry};
let tmpl = Template::parse(src)?; // Result<Template, ParseError>
let html = tmpl.render(&ctx, &registry)?; // Result<String, RenderError>
let mani = tmpl.manifest(); // Manifest
// SSR pattern: page = head + html + "<script data-wo-manifest>" + mani.to_json() + "</script>"
```
## Exit criteria
1. `cargo build -p ui` green; no new top-level dependencies beyond `serde`/`serde_json`.
2. **Golden parse + render** for every `.htmlx` file under [`docs/examples/blog/ui/components/`](../../examples/blog/ui/components/) and [`docs/examples/ecommerce/shared/components/`](../../examples/ecommerce/shared/components/) — output is HTML and parses back into an isomorphic AST.
3. **Manifest emission** for `<wo:live source="Order{ status != Cancelled }" key="id" sort="placed_at desc">…</wo:live>` produces a `LiveSubscription` with the source string preserved verbatim and the body wrapped under `data-wo-subscription="orders-live-0"`.
4. **Bind-site collection** for `<td wo:bind="status">{{status}}</td>` inside `<wo:live>` records `(subscription_id, key="id", field="status")` once and only once.
5. **v1 regression**: `cargo run --bin wo -- run docs/examples/blog` continues to start without parser errors. The blog sample has no `<wo:live>` or `wo:bind` today; nothing should regress.
6. All 14 existing `crates/rt` tests pass.
## Non-scope
- **No SSR.** Phase 02 emits templates; phase 03 ships the runtime; serving them is a downstream concern that the per-app binary in phase 05 wires together.
- **No nested `<wo:live>`.** Parse error in this phase. Author can compose live subtrees by partial inclusion (`{{> child}}`).
- **No author-extensible helpers.** The closed enum is the contract for this phase.
- **No streaming render.** Templates are rendered to a single `String`.
- **No manifest version negotiation.** Wire format is `version: 1` always.
## Verification
```bash
cargo build -p ui
cargo test -p ui --test golden_v1 # blog/ecommerce templates byte-identical
cargo test -p ui --test golden_extensions # <wo:live> + wo:bind cases
cargo test -p ui --test manifest # manifest emission
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
Phase 02 (`02-ui-compiler.md`) consumes the `Template` + `Manifest` types defined here as its emission target — every `##ui` block compiles down to an `.htmlx` file that parses cleanly under this phase's parser. Phase 03 (`03-client-runtime.md`) consumes the manifest JSON schema as its wire input.

View file

@ -0,0 +1,95 @@
# 02 — `##ui` → `.htmlx` compiler
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Sub-phase sequence" (L172–180), "Design decisions" 1–6, [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) (the emission target), [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) (the `##ui` block spec), [`docs/examples/blog/ui/article_list.wo`](../../examples/blog/ui/article_list.wo), [`docs/examples/blog/ui/article_detail.wo`](../../examples/blog/ui/article_detail.wo), [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) (the test corpus), [`crates/rt/src/parser.rs:80–116`](../../../crates/rt/src/parser.rs) (the parse-and-discard call site to replace).
## Goal
Walk a parsed `##ui` block — every key listed in 00-overview's locked grammar (`title`, `source`, `live`, `role`, `filter`, `quick-filters`, `columns`, `sort`, `actions`, `pagination`, `refresh`, `highlight-new`, `key`, `sections`, `inputs`, `use`, `with`, `template`, `styles`) — and emit a complete `.htmlx` template that parses under phase 01's grammar. When a hand-written `<screen>.htmlx` sits beside the `.wo`, the compiler honours it and only validates that the manifest still aligns.
## Design decisions (locked)
1. **File-presence dispatch.** If `apps/<X>/ui/<screen>/<screen>.htmlx` exists alongside `<screen>.wo`, the hand-written file wins. The compiler still emits a manifest; it errors if the manifest's `bind_sites` reference fields the hand-written template doesn't expose. Anchored in [`./00-overview.md`](./00-overview.md) L24, L30, L46.
2. **`live: true` ⇒ `<wo:live>` wrapper.** The compiler emits `<wo:live source="<source>" key="<key|id>" sort="<sort.default|nothing>" filter="<filter|nothing>">` around the auto-generated `<table>`. `key` defaults to `id` when not declared.
3. **`renderer: <name>` ⇒ helper invocation by rule table.** A static rule table maps each renderer name to its emission form: `markdown` → `{{markdown <field>}}`, `money` → `{{> money amount=<field>}}`, `relative-date` → `{{relative <field>}}`, `code` / `tag-chips` / `pill` / `image` / `stock-badge` / `list` similarly. The set is closed and matches phase 01's helper registry.
4. **`actions: row-* / bulk-*` ⇒ `data-action` + `data-role` attributes.** Click → POST `/api/fn/<fn>`. Role gating is a `data-role="<set>"` attribute the runtime hides on; phase 07 wires the server-side check.
5. **Generated templates land in `target/wo/<app>/ui/<screen>.htmlx`.** Same path the per-app binary in phase 05 reads from at startup. Build artefact, not committed.
6. **`crates/rt/src/parser.rs:80–88` no longer skips `##ui`.** The `Kind::HashHash` arm parses into a `Screen` IR (this phase's new type). All other `##` blocks (`##app`, `##component`) keep their current `skip_top_level_chunk` behaviour for now — `##app` is owned by phase 05 and `##component` parses inline as a sibling of `##ui` but emits no template (it's a partial that other screens reference).
## Scope
### New files inside `crates/ui/src/compiler/`
| File | Responsibility | Port source |
| --- | --- | --- |
| `mod.rs` | Re-exports `compile_screen`, `Screen`, `Column`, `Action`, `CompileError` | new (~30 LOC) |
| `screen.rs` | `Screen` IR — every key listed in the goal section above | new (~150 LOC) |
| `codegen.rs` | Walk `Screen` → emit `.htmlx` source string | new (~280 LOC) |
| `renderers.rs` | Closed `renderer:` → helper-emission rule table | new (~120 LOC) |
| `fallback.rs` | File-presence dispatch + manifest cross-check | new (~80 LOC) |
### Modified file
| File | Change | Notes |
| --- | --- | --- |
| `crates/rt/src/parser.rs` | Replace the `Kind::HashHash(_)` skip arm at L80–88 with a real parse into `Screen` when the tag is `ui` | +60 LOC delta |
Total: ~720 LOC (all new — the v1 codebase has no `##ui` precedent to port).
### `Cargo.toml` change
`crates/ui` already depends on `serde`/`serde_json` from phase 01. No new deps.
## API shape (target)
```rust
use ui::compiler::{compile_screen, compile_app, Screen};
let screen: Screen = ql::parse_ui_block(src)?;
let template: String = compile_screen(&screen, &ctx)?; // an .htmlx string
let mani = ui::htmlx::Template::parse(&template)?.manifest();
// Whole-app pipeline used by phase 05's `wo build`:
let outputs: Vec<(PathBuf, String)> = compile_app(&app_dir)?;
for (path, src) in outputs { fs::write(path, src)?; }
```
## Exit criteria
1. `cargo build -p ui` and `cargo build -p rt` green.
2. **Compile every sample `##ui`** in the test corpus: blog `article_list.wo`, blog `article_detail.wo`, ecommerce `apps/storefront/ui/home/home.wo`, `apps/storefront/ui/orders/orders.wo`, `apps/admin/ui/orders/orders.wo`. Output template parses cleanly under phase 01.
3. **Hand-written fallback honoured.** With a hand-written `apps/admin/ui/orders/orders.htmlx` present, the compiler returns its source unchanged but still emits the manifest.
4. **Manifest cross-check fires.** Renaming `body` to `text` in a hand-written template that the `##ui` block expects under `wo:bind="body"` produces a `CompileError::HandWrittenMissingField` diagnostic.
5. **Parser change is non-breaking.** `crates/rt`'s 14 unit tests still pass; `cargo run --bin wo -- run docs/examples/blog` boots and serves REST as before.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No SSR.** This phase emits files only — the runtime in phase 05 reads them at startup.
- **No author-defined renderers.** The closed table from phase 01's helper registry is the contract.
- **No build-output caching.** The compiler runs on every `wo build`. Caching is a future concern.
- **No partial recovery.** First parse or codegen error aborts compilation for the screen; whole-app compilation reports per-screen status.
- **No `##component` codegen in this phase.** Components remain their existing partial-include shape (`{{> name args}}`) — only `##ui` screens drive new emission.
## Verification
```bash
cargo build -p ui -p rt
cargo test -p ui --test compile_blog
cargo test -p ui --test compile_ecommerce
cargo test -p ui --test fallback_handwritten
# end-to-end: emit templates for the storefront and inspect them
cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront --emit-templates-only
ls target/wo/storefront/ui/ # home.htmlx, orders.htmlx
head -1 target/wo/storefront/ui/orders.htmlx # starts with <wo:live source="Order{ … }">
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
Phase 03 (`03-client-runtime.md`) is now unblocked: every screen has a manifest the client runtime can read. Phase 04 (`04-workspace-layout.md`) places the compiled outputs under `target/wo/<app>/ui/`, which phase 05 then bakes into the per-app binary.

View file

@ -0,0 +1,109 @@
# 03 — Client runtime
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "`.htmlx` with live subscriptions — target format" (L127–166) and decisions 1–2, [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) (the manifest schema this runtime consumes), [`reference/crates/wo-sub/src/lib.rs`](../../../reference/crates/wo-sub/src/lib.rs) (the v1 frame model the wire format mirrors), [`docs/examples/ecommerce/shared/components/order-row.htmlx`](../../examples/ecommerce/shared/components/order-row.htmlx) (the live workload the runtime must update without reload).
## Goal
Ship a ~500-line vanilla-JS client at `crates/ui/assets/wo-runtime.js` that, on page load, reads the `<script data-wo-manifest>` JSON, opens one WebSocket back to the originating app, subscribes to every declared `LiveSubscription`, and patches DOM in place on `Insert`/`Update`/`Delete` frames — without a framework, without a build step, served as a static asset at `/_wo/runtime.js`.
## Design decisions (locked)
1. **Vanilla JS, no transpiler.** The file shipped is the file written. Anchored in [`./00-overview.md`](./00-overview.md) L25, L198–199.
2. **JSON over WebSocket.** Frame schema mirrors `reference/crates/wo-sub` semantics evolved into this phase's predicate-subscription model. `{ subscription_id, kind: "snapshot"|"insert"|"update"|"delete", key, row|fields }`.
3. **Targeted DOM patching, not virtual-DOM.** `update` ⇒ `document.querySelectorAll('[data-wo-subscription="<id>"] [data-key="<k>"] [wo\\:bind="<f>"]')` ⇒ `el.textContent = row[f]`. Matches the Zone-less Angular note in 00-overview decision 9.
4. **Reconnect = full snapshot resync.** On reconnect the runtime re-subscribes and replaces each `<wo:live>` body with the fresh snapshot. No diff, no replay buffer.
5. **Backpressure = drop all but latest update per `data-key`.** A coalescing queue keyed by `(subscription_id, key)` collapses queued `update` frames; the latest wins. New frames of other kinds (`insert`/`delete`) flush the queue.
6. **Asset baked into binary, served at `/_wo/runtime.js`.** `crates/ui/src/runtime/asset.rs` does `pub const RUNTIME_JS: &[u8] = include_bytes!("../../assets/wo-runtime.js");`. Phase 05 mounts the route in the per-app binary.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/ui/assets/wo-runtime.js` | The runtime — reads manifest, opens WS, dispatches frames, patches DOM | new (~500 LOC JS) |
| `crates/ui/src/runtime/mod.rs` | Re-exports `RUNTIME_JS`, `runtime_etag()`, `Frame` | new (~20 LOC) |
| `crates/ui/src/runtime/asset.rs` | `include_bytes!` of the JS + sha256 ETag | new (~30 LOC) |
| `crates/ui/src/runtime/frame.rs` | Wire-frame `enum Frame` mirroring the JS schema | new (~120 LOC) |
Total: ~170 LOC Rust + ~500 LOC JS.
### `Cargo.toml` change
```toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10" # ETag for the runtime asset
```
`serde` / `serde_json` were already pulled in by phase 01. `sha2` is new for the asset ETag.
### Wire frame schema (mirrored in JS and Rust)
```json
{ "kind": "snapshot", "subscription_id": "orders-live-0", "rows": [ {…}, … ] }
{ "kind": "insert", "subscription_id": "orders-live-0", "key": "42", "row": {…} }
{ "kind": "update", "subscription_id": "orders-live-0", "key": "42", "fields": { "status": "Paid" } }
{ "kind": "delete", "subscription_id": "orders-live-0", "key": "42" }
```
## API shape (target)
```rust
use ui::runtime::{RUNTIME_JS, runtime_etag, Frame};
// Phase-05 router mounts:
router.route(Method::GET, "/_wo/runtime.js", |_req| {
Response::ok()
.header("Content-Type", "application/javascript")
.header("ETag", runtime_etag())
.body(RUNTIME_JS.to_vec())
});
// Phase-06 wire-protocol handler emits frames:
let frame = Frame::Update { subscription_id: id.into(), key: k.into(), fields: patch };
ws.send_text(serde_json::to_string(&frame)?)?;
```
## Exit criteria
1. `cargo build -p ui` green; `wc -c crates/ui/assets/wo-runtime.js` ≤ 25 600 bytes (25 KB cap, slack on the 20 KB target).
2. **Frame round-trip test.** A Rust unit test serialises one of each `Frame` variant; a Node-driven test (`node --test`) parses the same JSON, asserts shape.
3. **DOM-patch test (jsdom).** `node crates/ui/runtime-tests/run.mjs` loads a stub HTML containing one `<wo:live>` block and a manifest, fakes a WebSocket emitting `snapshot` → `insert` → `update` → `delete` frames, and asserts each patch hits the right element.
4. **Reconnect test.** Killing the fake WS triggers exponential backoff; on resume the runtime re-issues subscriptions and replaces the body with the new snapshot.
5. **Asset served.** Once phase 05 lands, `curl http://127.0.0.1:8080/_wo/runtime.js` returns the file with a stable `ETag` matching `sha256(RUNTIME_JS)`.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No browser test matrix.** jsdom is the only target; Playwright/headless-Chrome are deferred.
- **No optimistic UI.** Action buttons (`data-action="…"`) POST and wait — no client-side state mutation before the server confirms.
- **No client-side routing.** Page navigation is full-page reload.
- **No framework integration.** No React/Vue/Solid bindings.
- **No gzip / Brotli.** The JS ships uncompressed; HTTP-level compression is a phase-08-style concern.
- **No `<noscript>` fallback.** Pages with `<wo:live>` without JS show the SSR snapshot frozen.
## Verification
```bash
cargo build -p ui
cargo test -p ui --test wire_frames
# JS unit test (jsdom) — repo will need node ≥ 20
node crates/ui/runtime-tests/run.mjs
# Size budget
wc -c crates/ui/assets/wo-runtime.js
test "$(wc -c < crates/ui/assets/wo-runtime.js)" -le 25600
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
Phases 01 + 02 + 03 together cover the prototype demo path: a `##ui` block compiles to `.htmlx`, the SSR pass renders it with a manifest, the runtime opens a WebSocket and patches the DOM on commit. Phase 05 (`05-per-app-binaries.md`) bakes `RUNTIME_JS` into each app binary; phase 06 (`06-shared-db-daemon.md`) is the WebSocket origin that emits the frames defined here.

View file

@ -0,0 +1,139 @@
# 04 — Workspace layout + `wo.toml` grammar
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Target layout" (L73–117), "Design decisions" 4–5 (L33–34), [`docs/examples/ecommerce/wo.toml`](../../examples/ecommerce/wo.toml) (the workspace manifest already in the repo), [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../examples/ecommerce/apps/storefront/wo.toml) (the per-app manifest already in the repo), [`docs/examples/blog/`](../../examples/blog/) (the degenerate single-app form).
## Goal
Lock the `wo.toml` grammar at both workspace and per-app scope, fill any structural gaps in `docs/examples/ecommerce/` so its layout matches 00-overview's "Target layout" exactly, and ship a Rust workspace loader (`crates/app/src/workspace.rs` + sibling) that walks a workspace root, reads each app's manifest, and resolves `{{> partial}}` lookups across `shared = […]` directories first-match-wins.
## Design decisions (locked)
1. **`wo.toml` is TOML.** Not `.wo`. The workspace + app manifests already exist in the repo using TOML; this phase formalises the schema and adds a parser. Anchored in [`docs/examples/ecommerce/wo.toml`](../../examples/ecommerce/wo.toml).
2. **Path-based shared dependencies, no registry.** `shared = ["../../shared/types", …]` resolves at parse time relative to the per-app `wo.toml`. No semver, no fetch. Anchored in [`./00-overview.md`](./00-overview.md) decision 4.
3. **Workspace root vs. single app, by `kind`/`app_kind` field.** A `wo.toml` with `kind = "workspace"` triggers workspace loading and reads `[workspace]`. A `wo.toml` with `app_kind = "app"` is a leaf app. A `wo.toml` with neither is a degenerate single-app workspace (the blog example) — loaded as if it were `apps/<itself>`.
4. **One screen per directory.** `apps/<X>/ui/<screen>/{<screen>.wo, <screen>.htmlx, <screen>.css}` is locked layout. The loader walks `apps/<X>/ui/*/` and registers each subdirectory as a screen. Anchored in [`./00-overview.md`](./00-overview.md) decision 5 + Target Layout (L98–103).
5. **Component resolution: app-local first, then shared in declared order.** `{{> money amount=x}}` looks in `apps/<X>/ui/components/` first, then each path in `[dependencies] shared = […]` in order. First match wins. Errors at compile time if the partial cannot be resolved.
## Scope
### New files inside `crates/app/src/`
| File | Responsibility | Port source |
| --- | --- | --- |
| `workspace.rs` | Parse workspace `wo.toml`, walk and load each app | new (~200 LOC) |
| `manifest.rs` | Per-app `wo.toml` schema (`AppManifest`) | new (~150 LOC) |
| `resolver.rs` | Component / type / logic path resolution across `shared = […]` | new (~120 LOC) |
| `mod.rs` | Re-exports `Workspace`, `App`, `AppManifest`, `WorkspaceError`, `ResolverError` | new (~30 LOC) |
Total: ~500 LOC.
### Existing files filled in (not new code)
`docs/examples/ecommerce/` — gap-fill any directories named in 00-overview's "Target layout" that don't yet exist (e.g. `shared/components/header.htmlx`, `apps/storefront/types/cart.wo`). Pure structural work; no runtime change.
### `Cargo.toml` change
```toml
[dependencies]
toml = "0.8"
```
`toml` is the explicit cost of skipping a hand-rolled TOML parser at this phase. The dependency-removal track will revisit when relevant; for prototype, parsing TOML by hand isn't worth the LOC.
### Workspace `wo.toml` schema
```toml
name = "ecommerce-workspace"
version = "0.1.0"
kind = "workspace"
[runtime]
wo = ">= 0.1"
[workspace]
apps = ["apps/storefront", "apps/admin"]
shared = ["shared/types", "shared/logic", "shared/components"]
[database]
listen = "127.0.0.1:5555"
data_dir = "./data"
isolation = "snapshot"
```
### Per-app `wo.toml` schema
```toml
name = "storefront"
version = "0.1.0"
app_kind = "app"
[dependencies]
shared = ["../../shared/types", "../../shared/logic", "../../shared/components"]
[server]
listen = ":8080"
[database]
url = "wo://127.0.0.1:5555"
api_key_env = "STOREFRONT_DB_KEY"
```
## API shape (target)
```rust
use app::{Workspace, App};
let ws = Workspace::load(Path::new("docs/examples/ecommerce"))?;
assert_eq!(ws.apps().len(), 2);
let storefront = ws.app("storefront").unwrap();
let money_partial = storefront.resolve_component("money")?; // shared/components/money.htmlx
let cart_type = storefront.resolve_type("Cart")?; // apps/storefront/types/cart.wo
// Degenerate form:
let blog = Workspace::load(Path::new("docs/examples/blog"))?; // single-app workspace
assert_eq!(blog.apps().len(), 1);
```
## Exit criteria
1. `cargo build -p app` green; the new `toml` dep compiles.
2. **Workspace load.** `Workspace::load(docs/examples/ecommerce)` returns 2 apps + 3 shared dirs and no errors.
3. **Component resolution.** `storefront.resolve_component("money")` returns the path to `shared/components/money.htmlx`. `storefront.resolve_component("nonsense")` errors as `ResolverError::NotFound`.
4. **App-local override.** Adding `apps/storefront/ui/components/money.htmlx` makes `resolve_component("money")` return the app-local path; removing it falls back to the shared one.
5. **Degenerate form.** `Workspace::load(docs/examples/blog)` loads as a one-app workspace; `wo run docs/examples/blog` continues to start unchanged.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No semver, no registry, no lockfile.** Path references only.
- **No `wo dev` hot-reload.** File watching against `apps/*/ui/` is deferred (would consume `reference/crates/wo-watch/`).
- **No cross-workspace symlinks.** `shared = […]` paths must resolve under the workspace root.
- **No build-time enforcement that an app touches only its declared shared dirs.** That's an integrity check for a later hardening phase.
- **No env-var interpolation in `wo.toml`.** `${VAR}` syntax stays out; runtime config comes through env vars at startup, not manifest time.
## Verification
```bash
cargo build -p app
cargo test -p app --test workspace_load
cargo test -p app --test resolver
# end-to-end inspection
cargo run --bin wo -- ls-apps docs/examples/ecommerce
# storefront apps/storefront
# admin apps/admin
cargo run --bin wo -- ls-apps docs/examples/blog
# blog .
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
The workspace layout is the input that phase 05 (`05-per-app-binaries.md`) compiles into a binary per app, and the namespace within which phase 06 (`06-shared-db-daemon.md`) issues per-app API keys. Phase 07 (`07-per-app-policies.md`) reads `apps/<X>/app.wo` for app-scope policy declarations whose location this phase locks.

View file

@ -0,0 +1,124 @@
# 05 — Per-app static binaries (`wo build apps/X`)
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Goal" (L21–23), "Design decisions" 3–4 (L32–33), [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md), [`./02-ui-compiler.md`](./02-ui-compiler.md), [`./03-client-runtime.md`](./03-client-runtime.md), [`./04-workspace-layout.md`](./04-workspace-layout.md), [`./06-shared-db-daemon.md`](./06-shared-db-daemon.md) (the wire URL contract this phase consumes), [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../examples/ecommerce/apps/storefront/wo.toml).
## Goal
`wo build apps/<X>` produces a single static binary at `target/wo/<X>` that contains the app's `##ui` / `##app` blocks compiled to `.htmlx`, the imported `shared/` dirs the app's `wo.toml` names, the vanilla-JS client runtime from phase 03, and a thin `main()` that reads `WO_DB`, opens a wire connection to the shared daemon, and serves the public HTTP listener declared in `[server] listen`.
## Design decisions (locked)
1. **One Cargo build per app, dynamic Cargo project templating.** `wo build apps/<X>` materialises a Cargo project under `target/wo-build/<X>/`, fills `[bin] name = "<X>"`, copies/generates `app_config.rs`, and invokes `cargo build --release`. The resulting binary is copied to `target/wo/<X>`.
2. **No per-app Rust source generation beyond config.** The same `crates/app` is linked into every app binary. The only generated Rust file is `app_config.rs` containing the route table, embedded templates, and embedded runtime asset. Avoids exploding cargo metadata across N apps.
3. **`include_bytes!` bakes templates + runtime + CSS at compile time.** A Cargo `build.rs` writes `app_config.rs` enumerating every compiled `.htmlx`, every `.css` from `apps/<X>/ui/<screen>/<screen>.css` and `shared/components/*.css`, plus the runtime JS via `RUNTIME_JS` from phase 03.
4. **Connection target precedence: `WO_DB` env > `[database].url` from manifest > error.** The app refuses to start if neither is set. Anchored in [`./00-overview.md`](./00-overview.md) "Goal" (L23) and [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../examples/ecommerce/apps/storefront/wo.toml) L21–26.
5. **HTTP listener address comes from `[server] listen`.** Different from the database URL — the database URL is what this binary connects *to*; `[server].listen` is what the binary itself exposes to browsers. `WO_LISTEN` env var overrides for ops.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/app/src/build.rs` | `wo build apps/<X>` driver: template Cargo project, run cargo, copy binary | new (~250 LOC) |
| `crates/app/build.rs` (Cargo build script) | Generates `app_config.rs` with embedded templates + runtime + routes | new (~100 LOC) |
| `crates/app/src/main.rs` | App-binary entrypoint: reads `WO_DB`, opens wire, starts HTTP | new (~150 LOC) |
| `crates/app/src/route_table.rs` | Compile-time route table from `app.wo` routes block | new (~120 LOC) |
| `crates/rt/src/bin/wo.rs` | Wire `wo build <path>` subcommand | modify (+40 LOC) |
Total: ~660 LOC new + ~40 LOC modified.
### `Cargo.toml` change
`crates/app` adds itself as a workspace member that produces a binary. No new external deps beyond what phases 01–04 already brought in.
```toml
[[bin]]
name = "wo-app"
path = "src/main.rs"
```
### Generated `app_config.rs` shape
```rust
// Generated by crates/app/build.rs — do not edit.
pub const APP_NAME: &str = "storefront";
pub const APP_LISTEN: &str = ":8080";
pub const APP_DB_URL: Option<&str> = Some("wo://127.0.0.1:5555");
pub const APP_API_KEY_ENV: &str = "STOREFRONT_DB_KEY";
pub const TEMPLATES: &[(&str, &[u8])] = &[
("home", include_bytes!("../target/wo/storefront/ui/home.htmlx")),
("orders", include_bytes!("../target/wo/storefront/ui/orders.htmlx")),
];
pub const ROUTES: &[(&str, &str, &str)] = &[
("GET", "/", "ui.home"),
("GET", "/orders", "ui.orders"),
];
```
## API shape (target)
```rust
// build-time API used by the wo CLI:
use app::build;
let binary_path: PathBuf = build::build(Path::new("docs/examples/ecommerce/apps/storefront"),
Path::new("target/wo"))?;
// runtime: every app binary's main() looks like this
fn main() -> Result<()> {
let db_url = env::var("WO_DB").ok()
.or(app_config::APP_DB_URL.map(str::to_owned))
.ok_or(BootError::NoDatabase)?;
let api_key = env::var(app_config::APP_API_KEY_ENV)?;
let db = wire::connect(&db_url, &api_key)?;
let r = build_router(&app_config::ROUTES, &app_config::TEMPLATES, db);
rt::http::serve(app_config::APP_LISTEN, r)
}
```
## Exit criteria
1. `cargo build -p app` green; `crates/app` produces the `wo-app` library + the `wo build` driver.
2. **Storefront builds.** `cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront` produces `target/wo/storefront`. `file target/wo/storefront` reports an ELF executable.
3. **Storefront boots.** `WO_DB=wo://127.0.0.1:5555 STOREFRONT_DB_KEY=test ./target/wo/storefront &` then `curl -fsS http://127.0.0.1:8080/healthz` returns `200`. (The DB daemon from phase 06 is mocked or stubbed for this test if 06 hasn't landed yet — refuse-to-start without DB is the contract; the test verifies refuse-to-start when `WO_DB` is unset.)
4. **Admin builds separately.** `wo build apps/admin` produces a *different* binary with a disjoint route table. Diffing the two `app_config.rs` files shows different route lists.
5. **Refuse-to-start without DB.** `./target/wo/storefront` with no `WO_DB` and no manifest URL exits non-zero with a clear error.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No cross-compilation.** Linux x86_64 only this phase. `--target` flags are passed through but untested.
- **No musl static linking.** glibc-linked binaries are fine for prototype.
- **No container packaging, no systemd unit generation.**
- **No binary-size optimisation** beyond `--release`. `wo build --strip` is a future flag.
- **No incremental compile cache management.** `target/wo-build/<X>/` is reused across builds but not pruned.
## Verification
```bash
cargo build -p app
# storefront build + boot
cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront
file target/wo/storefront # ELF 64-bit
ls -la target/wo/storefront/ui/ # home.htmlx, orders.htmlx (compiled in phase 02)
# refuse-to-start without WO_DB
./target/wo/storefront 2>&1 | grep -q "WO_DB"
# admin builds independently
cargo run --bin wo -- build docs/examples/ecommerce/apps/admin
test -x target/wo/admin
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
`wo build` produces shippable per-app binaries; what they connect to is owned by phase 06 (`06-shared-db-daemon.md`), and the policy gate they enforce on every query is owned by phase 07 (`07-per-app-policies.md`). After 06 + 05 + 07 land together, the prototype demo path closes: `wo db serve` + `wo build apps/storefront` + `wo build apps/admin` running side by side, sharing one data dir, with admin live updates triggered by storefront commits.

View file

@ -0,0 +1,120 @@
# 06 — Shared database daemon (`wo db serve`)
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Goal" (L23), "Design decisions" 3 (L32), "Non-scope" (L201–203), [`./03-client-runtime.md`](./03-client-runtime.md) (the wire frames this daemon emits), [`./05-per-app-binaries.md`](./05-per-app-binaries.md) (the apps that connect), [`reference/crates/wo-sub/src/lib.rs`](../../../reference/crates/wo-sub/src/lib.rs) (the v1 subscription registry, 470 LOC, that needs generalising past `ByTitle`/`ByTag`/`All`), [`../../runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md) (the wire-protocol owner).
## Goal
Stand up a headless daemon — `wo db serve` — that runs the engine + WAL + subscription registry behind the Phase-4 native wire protocol on `127.0.0.1:5555`, with no HTTP and no template rendering. Each connection presents an API key from a static table, binds a `Principal { app, roles }` for downstream policy evaluation, and can register `Subscription::ByPredicate` against any type — a generalisation of v1's article-only subscription model that this phase ports and broadens.
## Design decisions (locked)
1. **Daemon = `crates/db` thin entrypoint + `crates/engine` + the wire acceptor.** No HTTP, no `.htmlx`, no `##ui`. The shared DB process knows nothing about the UI layer.
2. **API-key table is in-memory, env-seeded.** On startup the daemon reads `WO_DB_KEY_<APP>=<hex>` for each app declared in the workspace and builds an `AuthTable: HashMap<ApiKey, Principal>`. A `--keys <file>` flag is accepted but treated as a future hook.
3. **Generalise `wo-sub`** from `Subscription::ByTitle/ByTag/All` to `Subscription::ByPredicate(TypeRef, Expr, SortKey)`. The v1 variants stay as legacy aliases (`ByTitle(t)` ⇒ `ByPredicate(Article, sys_title == t, _)`) for the blog regression test. Anchored in [`reference/crates/wo-sub/src/lib.rs`](../../../reference/crates/wo-sub/src/lib.rs) L8–17.
4. **Connection scope = `Principal { app, roles }` stored on the connection.** Every query evaluator reads it; phase 07 wires it into policy AND-composition.
5. **One data dir, one engine, many connections.** Snapshot isolation by default (per `[database].isolation = "snapshot"` in the workspace `wo.toml`).
6. **Foreground-only this phase.** No daemonisation, no PID file, no signal handling beyond `SIGTERM` graceful shutdown. A future ops doc can add `wo db daemonize`.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/db/src/main.rs` | Entrypoint, arg parsing, env-key loading | new (~100 LOC) |
| `crates/db/src/server.rs` | Wire-protocol acceptor (TCP listener + per-conn handler) | new (~250 LOC) |
| `crates/db/src/auth.rs` | `AuthTable`, `Principal`, key handshake | new (~120 LOC) |
| `crates/sub/src/lib.rs` | Generalised subscription manager | port [`reference/crates/wo-sub/src/lib.rs`](../../../reference/crates/wo-sub/src/lib.rs) (470 LOC) + ~150 new |
| `crates/sub/src/predicate.rs` | Predicate evaluation against a row (uses `crates/ql` if available, else minimal subset) | new (~150 LOC) |
Total: ~1240 LOC (470 ported + ~770 new).
### `Cargo.toml` change
`crates/db` becomes a binary target:
```toml
[[bin]]
name = "wo-db"
path = "src/main.rs"
```
Plus deps already in the workspace: `serde`, `serde_json`, optionally `libc` for the listener (matching `crates/rt`'s direction).
### Wire handshake (added to Phase 4 protocol)
```
client → server: HELLO app="storefront" api_key="<hex>"
server → client: WELCOME principal={ app, roles } | ERROR "unauthorised"
```
After `WELCOME`, frames follow the Phase-4 native protocol. Subscription-registration frames carry `ByPredicate(TypeRef, Expr, SortKey)`.
## API shape (target)
```rust
use db::{DbServer, AuthTable, Principal};
use sub::{SubscriptionManager, Subscription};
let mut auth = AuthTable::new();
auth.insert(ApiKey::from_env("WO_DB_KEY_STOREFRONT")?,
Principal { app: "storefront".into(), roles: roles!("Customer") });
auth.insert(ApiKey::from_env("WO_DB_KEY_ADMIN")?,
Principal { app: "admin".into(), roles: roles!("Admin", "Ops") });
let server = DbServer::bind("127.0.0.1:5555", Path::new("./data"), auth)?;
server.run()?; // foreground; SIGTERM exits cleanly
// inside a connection handler:
let sub = Subscription::ByPredicate(
TypeRef::new("Order"),
parse_expr("status != Cancelled")?,
SortKey::new("placed_at", SortDir::Desc),
);
let id = subs.register(conn_fd, sub)?;
```
## Exit criteria
1. `cargo build -p db -p sub` green; `wo-db` binary produced under `target/release/`.
2. **Daemon starts.** `WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-test` runs foreground and accepts `SIGTERM`.
3. **Two principals.** Two clients connect, one with each API key; each receives a distinct `Principal` in the `WELCOME` frame.
4. **Predicate subscription.** Client registers `Subscription::ByPredicate(Order, "status != Cancelled", "placed_at desc")`; the manager returns a fresh `subscription_id`; on a stub `Order` insert, the matching client receives an `insert` frame.
5. **v1 regression.** A connection running the legacy `Subscription::ByTitle("hello-world")` against the blog corpus still produces notifications via the legacy alias.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **TLS.** `wo://` is plaintext this phase. TLS is its own phase later.
- **API-key rotation, revocation, expiry.** Static map only. JWT, mTLS, etc. — out of scope.
- **Multi-data-dir, replication, sharding.** One process, one data dir.
- **WAL changes.** Engine + WAL semantics inherit from the Stage-2 in-memory engine; persistent storage and crash recovery belong to the database series, not this phase.
- **Daemonisation, PID file, systemd integration.** Foreground-only.
- **Metric / structured-log emission.** Plain `eprintln!` traces only.
### Risk to flag in the doc
If `crates/ql` is too thin to evaluate `status != Cancelled` end-to-end at the time this phase lands, lock a minimal predicate subset — `==`, `!=`, `>`, `<`, `&&`, `||` against scalar fields — and document the gap explicitly. Phases that need richer predicates (graph traversals, computed fields) wait for `ql` to mature.
## Verification
```bash
cargo build -p db -p sub
# foreground daemon + two connections
WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb \
cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-test &
DB_PID=$!
cargo test -p db --test multi_app_principals
cargo test -p sub --test predicate_subscription
kill $DB_PID
# legacy v1 path
cargo test -p sub --test legacy_by_title
cd reference/crates && cargo build && cargo test
```
## After this phase
The wire URL contract is now stable, which unblocks phase 05 (per-app binaries) connecting to `wo://127.0.0.1:5555`. Phase 07 (`07-per-app-policies.md`) hooks its `EffectivePolicy` resolver into the query path inside this daemon — every query the daemon executes carries the connection's `Principal`, which is exactly what 07's AND-composition needs.

View file

@ -0,0 +1,105 @@
# 07 — Per-app policy composition
**Context sources:** [`./00-overview.md`](./00-overview.md) decision 7 (L36) and "Goal" (L26), [`./04-workspace-layout.md`](./04-workspace-layout.md) (where app-scope policies live), [`./06-shared-db-daemon.md`](./06-shared-db-daemon.md) (where composition is evaluated), [`docs/examples/blog/types/article.wo`](../../examples/blog/types/article.wo) and the other type files (existing global `policy read/write` blocks), [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) L18 (a `role: Admin | Ops` set expression).
## Goal
Layer per-app policy scopes (declared in `apps/<X>/app.wo` and per-`##ui` `role:` clauses) on top of the global per-type policies that already live next to type declarations, enforcing `effective = global AND app_scope`. An app can narrow what its connection sees, never broaden. A static check at `wo build` time refuses any app-scope that names a role outside the type's own policy domain.
## Design decisions (locked)
1. **AND-only composition.** App-scope can narrow, never broaden. Anchored in [`./00-overview.md`](./00-overview.md) decision 7 (L36).
2. **Three carrier locations.** (a) Global `policy read/write` blocks beside `type` declarations in `shared/types/<type>.wo` — already in the language. (b) `policy` blocks inside `apps/<X>/app.wo` — new structured form parsed in this phase. (c) `role: <RoleSet>` clauses inside `##ui` blocks and `actions:` rows — already in samples (e.g. [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) L18, L50–53). All three feed the same `EffectivePolicy` composer.
3. **Evaluation point = wire-protocol query handler in phase 06.** When a connection issues a query, the daemon reads `connection.principal`, fetches global + app-scope policies for the touched types, AND-composes, applies. No client-side enforcement; no compile-time inlining.
4. **`role:` is a set expression, not a single string.** `Admin | Ops` parses to `RoleSet::union(Admin, Ops)`. Anchored in [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) L18.
5. **Build-time domain check.** `wo build apps/<X>` walks every `role: <set>` referenced by the app and verifies each role is one the global type policy actually defines for that type. An app cannot mention `role: Anonymous` against a type whose global policy never permits anonymous access — that's a build error, not a runtime one.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/policy/src/scope.rs` | `AppScope`, `EffectivePolicy`, AND composer | new (~150 LOC) |
| `crates/policy/src/parse.rs` | Parse `role: A \| B` set expressions and `policy` blocks in `app.wo` | new (~100 LOC) |
| `crates/policy/src/check.rs` | Static build-time domain check | new (~100 LOC) |
| `crates/policy/src/mod.rs` | Re-exports | new (~30 LOC) |
### Modified files
| File | Change |
| --- | --- |
| `crates/db/src/server.rs` | Plug `EffectivePolicy::compose(global, app_scope, type)` into the per-query path; pass `connection.principal` through. (+50 LOC) |
| `crates/app/src/build.rs` | Run `policy::check::domain_check(app)` before invoking cargo. (+30 LOC) |
Total: ~460 LOC (all new — no v1 precedent for app-scope policy composition).
### `Cargo.toml` change
None beyond what phases 01–06 already added.
## API shape (target)
```rust
use policy::{GlobalPolicy, AppScope, EffectivePolicy, RoleSet};
// Composition (called per query in the daemon)
let effective = EffectivePolicy::compose(&global_for(&order_type),
&app_scope_for("admin"),
&order_type);
let allowed_rows = effective.filter(rows, &principal);
// Build-time domain check (called by `wo build apps/X`)
policy::check::domain_check(&app)?; // errors if any role: in app references undefined role
// Role set parser (used by both compiler and daemon)
let rs: RoleSet = "Admin | Ops".parse()?;
assert!(rs.contains(Role::Admin));
assert!(rs.contains(Role::Ops));
```
## Exit criteria
1. `cargo build -p policy -p db -p app` green.
2. **AND composition.** Unit test `compose(global = "published == true", app_scope = "owner == $session.id", t = Article)` returns an `EffectivePolicy` whose `allows_read` is true only when *both* clauses hold.
3. **Build-time domain check fires.** A test workspace where `apps/storefront/app.wo` declares `role: Anonymous` against a type whose global policy does not define `Anonymous` — `wo build apps/storefront` exits non-zero with `PolicyDomainError`.
4. **Cross-app integration.** Two storefront customers issue the same `GET /api/orders` against the daemon; each sees only their own rows (storefront app-scope narrows global). Admin sees both. Test runs against the phase-06 daemon.
5. **v1 regression.** `wo run docs/examples/blog` boots; the global `policy read for anyone when published == true` on the blog `Article` type continues to gate anonymous reads as it does today.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **Row-level write policies.** Read only this phase. Phase-6 spec mentions write composition; defer.
- **Field-level (column-level) policies.** All-or-nothing per row.
- **Audit logging of policy decisions.** No structured emission in this phase.
- **Policy versioning, migration, schema changes.** Whatever the type currently declares is the one definition.
- **Dynamic role assignment.** Roles are static per session; promotion / impersonation flows are out.
## Verification
```bash
cargo build -p policy -p db -p app
cargo test -p policy --test compose
cargo test -p policy --test domain_check
cargo test -p policy --test role_set_parse
# integration: two customers + one admin against the daemon
WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb \
cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-pol &
DB_PID=$!
cargo test --test cross_app_policy
kill $DB_PID
# v1 regression — blog policy unchanged
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1
curl -fsS http://127.0.0.1:8080/api/articles # only published rows
test -z "$(curl -fsS http://127.0.0.1:8080/api/articles | grep '"published":false')"
kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
The seven-doc UI track is complete. With phases 01–07 implemented end-to-end, the prototype demo path closes: `wo db serve` runs the shared daemon; `wo build apps/storefront` and `wo build apps/admin` produce two binaries with disjoint route tables and separate API keys; a checkout on the storefront fires a commit that the daemon broadcasts to admin's open `<wo:live>` subscription, the admin client runtime patches the orders table in place, and the same row never crosses storefront's narrower policy back to a different customer's session. After this phase, future hardening — TLS, key rotation, write-side composition, hot reload — gets its own track.