chore: keep only the log-watcher example (pre-merge cleanup)

- docs/examples/{agent-loop,blog,ecommerce,hello,mcp-think,pricing}
  removed; every deleted tree is preserved on branch
  cleanup/non-logwatcher-examples (snapshot of this branch pre-delete)
- justfile: hello/pricing/pricing-demo/pricing-pg-demo/hello-demo
  recipes removed with the examples they served (Rust-runtime demos);
  rt-c-* prototype recipes and every gate recipe stay
- crates/rt parser test article_debug: the blog fixture it read from
  disk now lives inline, same shape, so cargo test needs no example
- gates after cleanup: cargo test -p rt 69/0, oop-e2e 71/0, woc-test
  green, log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-15 09:06:26 +02:00
parent 691e43c009
commit 48429c5a4a
56 changed files with 87 additions and 3102 deletions

29
.planboard.json Normal file
View file

@ -0,0 +1,29 @@
{
"version": 1,
"plans": {
"docs/plan/compiler/2026-08-01-haxe-parity-language.md": {
"tasks": {
"111d92e6436d": {
"status": "doing",
"updatedAt": "2026-08-12T23:42:50Z"
},
"80e49fea186b": {
"status": "done",
"updatedAt": "2026-08-12T23:42:38Z"
},
"84addb11f795": {
"status": "done",
"updatedAt": "2026-08-12T23:42:35Z"
},
"942b4a1e8925": {
"status": "done",
"updatedAt": "2026-08-12T23:42:15Z"
},
"ea0f91fca71b": {
"status": "done",
"updatedAt": "2026-08-12T23:42:40Z"
}
}
}
}
}

View file

@ -0,0 +1,9 @@
---
planboard: generated
kind: ledger
plan: docs/plan/compiler/2026-08-01-haxe-parity-language.md
started: 2026-08-13
---
# Ledger — Haxe-Parity Language Adoptions Implementation Plan

View file

@ -1004,10 +1004,55 @@ type Order { status: Pending | Paid | Shipped }
#[test]
fn article_debug() {
let src = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"), "/../../docs/examples/blog/types/article.wo"
)).unwrap();
let sch = parse(&src).unwrap();
// formerly read docs/examples/blog/types/article.wo; the blog sample
// moved to the cleanup branch, so the fixture lives inline now —
// same shape: scalars, embedded doc, edges, backlink, computed,
// policies, triggers, service
let src = r#"
type Article {
id: Id
slug: Slug @unique
title: Text
author: ref Author
published: Bool = false
published_at: Timestamp?
created_at: Timestamp = now()
updated_at: Timestamp = now()
meta: {
excerpt: Text
body_md: Markdown
hero_image: Url?
reading_min: Int?
}
tags: multi Tag @edge(:TAGGED_AS)
related: multi Article @edge(:RELATED_TO)
prerequisites: multi Article @edge(:PREREQUISITE)
comments: backlink Comment.article
word_count: Int = words(meta.body_md)
policy read anyone when published == true
policy read for role Admin
policy read for role Author when author == $session.user
policy write for role Admin
policy write for role Author when author == $session.user
policy delete for role Admin
on update
when old.published == false and new.published == true
do set self.published_at = now()
do emit "article.published"(self)
do enqueue "send-subscriber-emails" with { article_id: self.id }
on update
do set self.updated_at = now()
service rest "/api/articles"
expose list, get, create, update, delete, subscribe
}
"#;
let sch = parse(src).unwrap();
eprintln!("types: {}", sch.types.len());
for t in &sch.types {
eprintln!(" type {} — {} fields, {} services", t.name, t.fields.len(), t.services.len());

View file

@ -1,148 +0,0 @@
# agent-loop — build the loop that turns a model into an agent
A guided, working implementation of an **agent loop** — the mechanism at the
core of Claude Code, opencode, Cursor, and every other "agentic" tool — in one
Python file, standard library only, running entirely on the **local model**
served by [`prototypes/llama-moe-stream/start-local-agents.sh`](../../../prototypes/llama-moe-stream/start-local-agents.sh)
(or any OpenAI-compatible endpoint, e.g. Ollama).
You already run opencode against the local Qwen3-Coder server. This example is
what opencode *is*, with the product stripped away: read
[`agent.py`](agent.py) top to bottom and you know how every coding agent works.
## 1. The idea
A language model only ever produces text. What makes it an *agent* is a loop
around it that (a) tells it what tools exist, (b) executes the tool calls it
emits, and (c) feeds the results back — until it stops asking:
```
messages = [system, user question]
loop:
reply = POST /v1/chat/completions (messages + TOOL SCHEMAS)
append reply to messages
if reply has no tool_calls: # the model is done
print reply.content; stop
for each tool_call in reply:
result = run it locally # YOUR code — the model never executes anything
append {role: "tool", content: result} to messages
```
Two properties fall out of this shape, and they are the whole mental model:
- **The transcript is the only state.** `messages` grows append-only; the
model re-reads the entire history every round. There is no other memory —
which is why the assistant's own tool-call message must be appended too, not
just the results (the model has to see *what it asked for* next round).
- **The model proposes, your process disposes.** Tool calls are requests in
JSON. The executor decides what actually happens — it is the security
boundary, so the agent is exactly as dangerous as its tools, never more.
## 2. The five pieces (each maps to a section of `agent.py`)
| # | Piece | In `agent.py` |
| --- | --- | --- |
| 1 | **A tool-calling endpoint** — `llama-server --jinja` applies Qwen's chat template so tool schemas go in and structured `tool_calls` come out | `chat()` |
| 2 | **Tool schemas** — the JSON contract shown to the model; descriptions are prompts, write them like documentation | `TOOLS` |
| 3 | **The executor** — dispatch, argument parsing, sandboxing; every failure returned as words, never raised | `execute()` |
| 4 | **The transcript** — one append-only `messages` list | `run_turn()` |
| 5 | **Stop conditions** — natural (no `tool_calls`) and budgeted (`MAX_TURNS`) | `run_turn()` |
The tools here are deliberately read-only (`list_dir`, `read_file`, `search`)
and confined to `AGENT_ROOT` — enough to make a useful repo-Q&A agent with
zero risk while you study the loop.
## 3. Run it
```bash
# 1. Start the local model (from prototypes/llama-moe-stream)
prototypes/llama-moe-stream/start-local-agents.sh # Qwen3-Coder on :8080
# 2. One-shot, over this repo
cd /path/to/writeonce-all
python3 docs/examples/agent-loop/agent.py "which file implements the shard bus, and how do cross-shard reads work?"
# 3. Interactive — the conversation persists across questions
python3 docs/examples/agent-loop/agent.py
```
Tool calls print to stderr as they happen (`⚙ search({"pattern": ...})`), so
you watch the loop investigate before it answers.
Against Ollama instead: `LLM_URL=http://localhost:11434/v1 LLM_MODEL=qwen3:4b
python3 agent.py ...` (Ollama serves the same OpenAI-compatible `/v1`; small
models tool-call noticeably worse than Qwen3-Coder-30B — that difference is
itself instructive).
## 4. The guards that keep the loop alive
The naive loop works until the model misbehaves — and it will. The one rule:
**never raise at the model; return the failure as the tool result.** A
tool-calling model reads the error and corrects itself next round; an
exception just kills the conversation.
| Failure | Guard in `agent.py` |
| --- | --- |
| Arguments aren't valid JSON | error string back: "resend the call with corrected JSON" |
| Tool name doesn't exist | error string back, listing the real tools |
| Wrong/missing/extra parameters | `TypeError` caught → described back |
| Tool output too big for the context | truncated at 8k chars with an instruction to narrow |
| Path outside the project | `_resolve()` refuses (symlinks resolved first) |
| Model never stops calling tools | `MAX_TURNS` budget ends the turn with a readable note |
## 5. Smoke-test without a model
```bash
python3 docs/examples/agent-loop/test_loop.py
```
Stands up a canned `/chat/completions` on a local port and scripts three
rounds — a real `read_file`, a tool call with deliberately broken JSON
arguments, then a final answer — asserting that results are fed back, errors
self-correct, and the loop terminates. This is the loop's mechanics verified
in milliseconds, no GGUF required.
## Configuration
| Variable | Default | Meaning |
| --- | --- | --- |
| `LLM_URL` | `http://127.0.0.1:8080/v1` | OpenAI-compatible base URL |
| `LLM_MODEL` | `qwen3-coder` | model name (`--alias` of the llama-server) |
| `LLM_TIMEOUT` | `300` | per-request timeout, seconds |
| `AGENT_ROOT` | current directory | sandbox root for all three tools |
| `MAX_TURNS` | `20` | tool rounds per user message |
## How this relates to writeonce
This is the third corner of the local-agent triangle in this repo:
- [`mcp-think`](../mcp-think/) is the **tool side** — a local model offered
*as tools to* an agent (Claude).
- [`prototypes/llama-moe-stream`](../../../prototypes/llama-moe-stream/) is
the **model side** — serving the MoE this loop drives.
- **This example is the agent side** — the harness itself.
The natural next step joins them to [plan 15](../../plan/15-mcp-streamable-http.md):
once the writeonce runtime speaks MCP over Streamable HTTP, the hardcoded
`TOOLS` list here gets replaced by an **MCP client** — `tools/list` supplies
the schemas, `tools/call` becomes the executor (the JSON-RPC lifecycle is
already demonstrated in [`mcp-think/test_client.py`](../mcp-think/test_client.py)).
Then this same ~40-line loop lets a fully local model operate a `.wo`
application: `article_create`, `set_price`, live resources — one catalog, one
engine, one port, no cloud.
## Ideas to build next
Each is a small, self-contained extension of the loop — and each corresponds
to a feature you use daily in Claude Code/opencode:
- **MCP tool source** — fetch schemas from an MCP server at startup instead of
hardcoding `TOOLS`; route `execute()` through `tools/call`. (= MCP support)
- **A `write_file` tool behind a y/n prompt** — the executor asks *you* before
acting. (= permission modes)
- **A `spawn_agent` tool** that runs a fresh `run_turn()` with its own
transcript and returns only the final answer. (= sub-agents)
- **Transcript compaction** — when `messages` outgrows the context window,
summarize the older rounds into one message. (= auto-compact)
- **Parallel tool execution** — a reply may carry several `tool_calls`; run
them concurrently, append results in order. (= parallel tool use)

View file

@ -1,285 +0,0 @@
#!/usr/bin/env python3
"""agent-loop — a complete agent in one file, on a local model.
The whole trick behind Claude Code, opencode, Cursor and every other
"agentic" tool is one loop:
send the transcript + tool schemas to the model
while the model answers with tool calls:
run the tools, append the results to the transcript
send again
print the final text
Everything else those tools add (permissions, context management,
sub-agents) is elaboration on that loop. This file IS the loop, small
enough to read in one sitting: an OpenAI-compatible chat endpoint
(llama-server from prototypes/llama-moe-stream, or Ollama) + three
read-only repo tools + the guards that keep the loop alive when the
model misbehaves.
Run (one-shot): python3 agent.py "what does crates/rt/src/shard.rs do?"
Run (interactive): python3 agent.py
Configuration (environment):
LLM_URL OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
LLM_MODEL model name / alias (default qwen3-coder)
LLM_TIMEOUT per-request timeout in seconds (default 300)
AGENT_ROOT directory the tools may touch (default: current directory)
MAX_TURNS tool rounds per user message (default 20)
Dependencies: Python standard library only.
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
LLM_URL = os.environ.get("LLM_URL", "http://127.0.0.1:8080/v1").rstrip("/")
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3-coder")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "300"))
ROOT = os.path.realpath(os.environ.get("AGENT_ROOT", os.getcwd()))
MAX_TURNS = int(os.environ.get("MAX_TURNS", "20"))
MAX_RESULT = 8_000 # chars of tool output fed back per call
SKIP_DIRS = {".git", "target", "node_modules", "build", "__pycache__", ".cache"}
# ---------------------------------------------------------------- the tools
# Schemas are the contract shown to the model; implementations are the
# security boundary. Read-only on purpose — an agent is exactly as dangerous
# as its tools, never more.
TOOLS = [
{"type": "function", "function": {
"name": "list_dir",
"description": "List one directory: entries with a trailing / for "
"subdirectories and a byte size for files.",
"parameters": {"type": "object", "properties": {
"path": {"type": "string",
"description": "directory, relative to the project root"},
}, "required": ["path"]}}},
{"type": "function", "function": {
"name": "read_file",
"description": "Read a text file with line numbers. Large files are "
"windowed — pass offset (1-based first line) and limit "
"(max lines) to page through.",
"parameters": {"type": "object", "properties": {
"path": {"type": "string", "description": "file, relative to the project root"},
"offset": {"type": "integer", "description": "first line to show, 1-based (default 1)"},
"limit": {"type": "integer", "description": "max lines to show (default 200)"},
}, "required": ["path"]}}},
{"type": "function", "function": {
"name": "search",
"description": "Search file contents under a directory with a Python "
"regular expression. Returns path:line: text matches. "
"Use a specific pattern — results cap at 100 matches.",
"parameters": {"type": "object", "properties": {
"pattern": {"type": "string", "description": "Python regex to find"},
"path": {"type": "string", "description": "directory to search, relative to the project root (default: whole root)"},
}, "required": ["pattern"]}}},
]
class ToolError(Exception):
"""A tool refusing to do something — reported to the model, never fatal."""
def _resolve(path: str) -> str:
"""Confine every path the model asks for to ROOT (symlinks resolved)."""
full = os.path.realpath(os.path.join(ROOT, path))
if full != ROOT and not full.startswith(ROOT + os.sep):
raise ToolError(f"path escapes the project root: {path}")
return full
def list_dir(path: str = ".") -> str:
full = _resolve(path)
if not os.path.isdir(full):
raise ToolError(f"not a directory: {path}")
rows = []
for name in sorted(os.listdir(full)):
p = os.path.join(full, name)
rows.append(f"{name}/" if os.path.isdir(p)
else f"{name} ({os.path.getsize(p)} bytes)")
return "\n".join(rows) or "(empty directory)"
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
full = _resolve(path)
if os.path.isdir(full):
raise ToolError(f"{path} is a directory — use list_dir")
try:
with open(full, errors="replace") as f:
lines = f.readlines()
except FileNotFoundError:
raise ToolError(f"no such file: {path}")
if not lines:
return "(empty file)"
offset = max(1, int(offset))
limit = max(1, min(int(limit), 1000))
window = lines[offset - 1: offset - 1 + limit]
if not window:
raise ToolError(f"{path} has only {len(lines)} lines; offset {offset} is past the end")
out = "".join(f"{i}\t{line}" for i, line in enumerate(window, offset))
last = offset + len(window) - 1
if last < len(lines):
out += (f"\n[agent] showing lines {offset}-{last} of {len(lines)} — "
f"call again with offset={last + 1} for more")
return out
def search(pattern: str, path: str = ".") -> str:
try:
rx = re.compile(pattern)
except re.error as e:
raise ToolError(f"bad regex {pattern!r}: {e}")
start = _resolve(path)
hits: list[str] = []
for dirpath, dirnames, filenames in os.walk(start): # never follows symlinks
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for fname in sorted(filenames):
full = os.path.join(dirpath, fname)
if os.path.islink(full) or os.path.getsize(full) > 2_000_000:
continue
try:
with open(full, errors="replace") as f:
head = f.read(1024)
if "\0" in head: # binary — skip
continue
f.seek(0)
for i, line in enumerate(f, 1):
if rx.search(line):
rel = os.path.relpath(full, ROOT)
hits.append(f"{rel}:{i}: {line.rstrip()[:200]}")
if len(hits) >= 100:
hits.append("[agent] 100-match cap hit — tighten the pattern or narrow the path")
return "\n".join(hits)
except OSError:
continue
return "\n".join(hits) or f"no matches for {pattern!r} under {path}"
TOOL_IMPLS = {"list_dir": list_dir, "read_file": read_file, "search": search}
# ---------------------------------------------------------- executing calls
# The one rule that keeps the loop alive: NEVER raise at the model. Whatever
# goes wrong — unknown tool, broken JSON, missing file — comes back as the
# tool result, in words. A tool-calling model reads the error and corrects
# itself on the next round; an exception would just kill the conversation.
def execute(call: dict) -> str:
fn_block = call.get("function") or {}
name = fn_block.get("name", "")
impl = TOOL_IMPLS.get(name)
if impl is None:
return f"[agent] unknown tool {name!r} — available: {', '.join(TOOL_IMPLS)}"
try:
args = json.loads(fn_block.get("arguments") or "{}")
except json.JSONDecodeError as e:
return f"[agent] arguments were not valid JSON ({e}) — resend the call with corrected JSON"
if not isinstance(args, dict):
return "[agent] arguments must be a JSON object"
try:
out = impl(**args)
except ToolError as e:
return f"[agent] {e}"
except TypeError as e:
return f"[agent] bad arguments for {name}: {e}"
except OSError as e:
return f"[agent] {name} failed: {e}"
if len(out) > MAX_RESULT:
out = (out[:MAX_RESULT] + f"\n[agent] truncated — {len(out)} chars total; "
"narrow the request (offset/limit, tighter pattern)")
return out
# ------------------------------------------------------------------ the loop
def chat(messages: list[dict]) -> dict:
"""One request to the model. Returns the assistant message verbatim."""
body = json.dumps({
"model": LLM_MODEL,
"messages": messages,
"tools": TOOLS,
"tool_choice": "auto",
}).encode()
req = urllib.request.Request(
f"{LLM_URL}/chat/completions",
data=body,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
data = json.load(resp)
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:400]
raise SystemExit(f"the model endpoint rejected the request (HTTP {e.code}): {detail}")
except (urllib.error.URLError, TimeoutError, OSError) as e:
raise SystemExit(
f"cannot reach the model at {LLM_URL} ({e}).\n"
"Start one first:\n"
" prototypes/llama-moe-stream/start-local-agents.sh # llama-server on :8080\n"
" LLM_URL=http://localhost:11434/v1 LLM_MODEL=qwen3:4b … # or a pulled Ollama model")
return data["choices"][0]["message"]
def run_turn(messages: list[dict]) -> str:
"""THE AGENT LOOP. Everything above exists to serve these few lines."""
for _ in range(MAX_TURNS):
msg = chat(messages)
messages.append(msg) # the transcript is the only state
calls = msg.get("tool_calls") or []
if not calls: # no tool call = the model is done
return _strip_think(msg.get("content") or "")
for call in calls:
fn = call.get("function") or {}
print(f" ⚙ {fn.get('name', '?')}({(fn.get('arguments') or '')[:120]})",
file=sys.stderr)
messages.append({
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": execute(call),
})
return (f"[agent] stopped after {MAX_TURNS} tool rounds without a final answer — "
"ask a narrower question or raise MAX_TURNS")
def _strip_think(text: str) -> str:
# Reasoning models may inline chain-of-thought as <think>…</think>;
# the user wants the conclusion, not the scratchpad.
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
# ----------------------------------------------------------------- the shell
SYSTEM = (f"You are a code assistant working inside the project rooted at {ROOT}. "
"Use the tools to look at real files before answering; never invent "
"file contents or paths. When you have enough evidence, answer "
"concisely and cite locations as path:line.")
def main() -> None:
messages: list[dict] = [{"role": "system", "content": SYSTEM}]
if len(sys.argv) > 1: # one-shot
messages.append({"role": "user", "content": " ".join(sys.argv[1:])})
print(run_turn(messages))
return
print(f"agent-loop — model {LLM_MODEL} at {LLM_URL}\n"
f"root {ROOT} (Ctrl-D to exit; the conversation persists across questions)")
while True: # interactive
try:
line = input("\nyou> ").strip()
except EOFError:
print()
return
if not line:
continue
messages.append({"role": "user", "content": line})
print(run_turn(messages))
if __name__ == "__main__":
main()

View file

@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""Smoke-test the agent loop without any model.
Stands up a canned OpenAI-compatible /chat/completions endpoint on a local
port and drives agent.run_turn() through a scripted conversation:
round 1: the "model" calls read_file on notes.txt -> executed for real
round 2: it sends a tool call with broken JSON args -> fed back as an error, not a crash
round 3: it returns a final answer
This verifies the three load-bearing behaviours of the loop: tool execution
with result feedback, errors-as-results self-correction, and termination on
a plain (tool-call-free) answer.
Usage: python3 test_loop.py # standard library only, no model needed
"""
import json
import os
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
MARKER = "the WAL fsyncs before acknowledging the commit"
# What the fake model answers, round by round.
SCRIPT = [
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_1", "type": "function", "function": {
"name": "read_file",
"arguments": json.dumps({"path": "notes.txt"})}}]},
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_2", "type": "function", "function": {
"name": "search",
"arguments": '{"pattern": '}}]}, # deliberately broken JSON
{"role": "assistant",
"content": f"FINAL: per notes.txt, {MARKER}."},
]
REQUESTS: list[dict] = []
class MockModel(BaseHTTPRequestHandler):
def do_POST(self):
REQUESTS.append(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
body = json.dumps({"choices": [{"message": SCRIPT[len(REQUESTS) - 1]}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
def main() -> int:
server = ThreadingHTTPServer(("127.0.0.1", 0), MockModel)
threading.Thread(target=server.serve_forever, daemon=True).start()
workdir = tempfile.mkdtemp(prefix="agent-loop-test-")
with open(os.path.join(workdir, "notes.txt"), "w") as f:
f.write(f"Durability rule: {MARKER}.\n")
# agent.py reads its configuration at import time — set env first.
os.environ["LLM_URL"] = f"http://127.0.0.1:{server.server_address[1]}/v1"
os.environ["LLM_MODEL"] = "mock"
os.environ["AGENT_ROOT"] = workdir
sys.path.insert(0, HERE)
import agent
answer = agent.run_turn([
{"role": "system", "content": "test"},
{"role": "user", "content": "what is the durability rule?"},
])
server.shutdown()
def tool_results(request: dict) -> list[str]:
return [m["content"] for m in request["messages"] if m.get("role") == "tool"]
assert len(REQUESTS) == 3, f"expected 3 model rounds, got {len(REQUESTS)}"
# Round 2's request must carry the real file content back as a tool result.
round2 = tool_results(REQUESTS[1])
assert any(MARKER in r for r in round2), f"file content not fed back: {round2}"
print("ok — tool call executed, result appended to the transcript")
# Round 3's request must carry the JSON error as a result, not a crash.
round3 = tool_results(REQUESTS[2])
assert any("[agent]" in r and "JSON" in r for r in round3), \
f"broken arguments not reported back: {round3}"
print("ok — malformed tool arguments came back as an error result")
assert answer.startswith("FINAL:") and MARKER in answer, f"unexpected answer: {answer}"
print("ok — loop terminated on the tool-call-free answer")
print(f"\nOK — the agent loop round-trips tools, self-corrects, and stops.\n"
f"Now run it against a real model: python3 {os.path.join(HERE, 'agent.py')}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,204 +0,0 @@
# `blog` — a sample writeonce app
A complete blogging website in **~200 lines of `.wo`** that creates a database, exposes REST + live-subscription endpoints, renders HTML pages, enforces row-level policies, and emits typed client SDKs.
> This project is a **docs artifact** — it illustrates the shape of a real `wo init`'d project. The `wo` toolchain referenced here is the one specified in [`../../runtime/wo-language.md`](../../runtime/wo-language.md); the engine is at prototype stage in [`../../../prototypes/wo-db/`](../../../prototypes/wo-db/).
## What it does
| Thing | How |
| --- | --- |
| Persists articles, authors, tags, comments | `type` declarations compiled to relational rows + embedded documents + graph edges |
| Serves 24 REST endpoints (CRUD + subscribe × 4 types) | `service rest` blocks on each type |
| Serves 4 web pages (list, detail, tag, admin) | `##ui` screens + route table in `app.wo` |
| Pushes live updates on every commit | `live: true` on screens + `LIVE` queries under the hood |
| Enforces "drafts hidden from anonymous readers" | `policy read anyone when published == true` |
| Bumps `published_at` automatically | `on update` trigger inside the transaction |
| Generates a typed Go client | `wo gen sdk --lang go` |
## Project layout
```
blog/
├── wo.toml # project manifest (like go.mod)
├── app.wo # routes, theme, startup hooks
├── types/
│ ├── author.wo # Author type + per-type service/policy
│ ├── 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
│ └── 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
$ cd docs/examples/blog
$ wo run
[wo] parsing: 7 files, 4 types, 2 ui screens
[wo] compiling schema: 4 sql tables, 1 doc collection, 3 graph edge types
[wo] starting runtime (engine: in-memory, data_dir: ./data)
[wo] on startup: seed_admin() — inserted admin@example.com
[wo] HTTP listening on :8080
GET /api/articles list
GET /api/articles/:id get
POST /api/articles create
PATCH /api/articles/:id update
DELETE /api/articles/:id delete
WS /api/articles/live subscribe
GET /api/authors list
GET /api/authors/me me
WS /api/authors/live subscribe
GET /api/tags list
GET /api/comments list
POST /api/comments create
WS /api/comments/live subscribe
... (and the rest)
GET / ui.article-list
GET /article/:slug ui.article-detail
GET /tag/:slug ui.article-list (filtered)
GET /admin ui.article-list (role: Admin)
```
## Exercise the REST API
```bash
# Create an author (requires admin session — see auth docs; stub'd here for brevity)
$ curl -X POST localhost:8080/api/authors \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"email":"alice@example.com","handle":"alice","display":"Alice","role":"Author"}'
{"id":2,"email":"alice@example.com","handle":"alice",...}
# Create an article as that author
$ curl -X POST localhost:8080/api/articles \
-H "Authorization: Bearer $ALICE_TOKEN" \
-d '{
"slug": "hello",
"title": "Hello, writeonce",
"author": 2,
"meta": {"excerpt":"First post","body_md":"# Hi\n\nHello."},
"published": true
}'
{"id":1,"slug":"hello","title":"Hello, writeonce","published_at":"2026-04-17T12:00:00Z",...}
# List published articles (public — no token)
$ curl localhost:8080/api/articles
[{"id":1,"slug":"hello","title":"Hello, writeonce",...}]
# Filter by tag (via the query layer)
$ curl 'localhost:8080/api/articles?tags.slug=rust'
[...]
```
## Subscribe to live updates
```bash
$ websocat ws://localhost:8080/api/articles/live?published=eq.true
{"kind":"snapshot","rows":[{"id":1,"slug":"hello",...}]}
# Now in another terminal, update article 1. The open socket receives:
{"kind":"update","id":1,"old":{"title":"Hello, writeonce"},"new":{"title":"Hello!"}}
```
No polling. The subscription predicate was registered at connect time; the engine's commit path emits the delta directly.
## Generate a Go client
```bash
$ wo gen sdk --lang go --out ./client
[wo] reading types from ./types/
[wo] writing ./client/sdk.go (4 types, 16 endpoints, 4 subscriptions)
```
Use it:
```go
import "github.com/you/blog/client"
c, _ := client.Connect(ctx, "wo://localhost:8080", client.WithToken(token))
// Typed query
articles, _ := c.Articles.List(ctx, client.Where{Published: ptr(true)})
// Typed subscription — deltas arrive on a channel
sub, _ := c.Articles.Subscribe(ctx, client.Where{Published: ptr(true)})
for d := range sub.C {
switch d.Kind {
case client.Insert:
fmt.Printf("new article: %s\n", d.Row.Title)
case client.Update:
fmt.Printf("updated: %s\n", d.Row.Slug)
}
}
```
## Run the tests
```bash
$ wo test
=== tests/article_test.wo ===
create and fetch by slug OK (3ms)
policy blocks public read of unpublished drafts OK (4ms)
graph traversal: related articles OK (7ms)
live subscription receives delta on commit OK (12ms)
PASS 4/4 tests, 0 failures (26ms)
```
Each `test` block runs against an isolated engine snapshot that's rolled back at the end — no setup/teardown code needed.
## Build a production binary
```bash
$ wo build --target linux-amd64 --out bin/blog
[wo] static binary: bin/blog (14 MB, database + HTTP + subscription engine embedded)
$ ./bin/blog
[wo] HTTP listening on :8080
```
One binary, no dependencies. Copy it to a server, run it, done. The database file lives in `./data/` relative to the binary; the WAL ensures crash safety ([Phase 3](../../runtime/database/03-inmemory-engine.md)).
## What to read next
- [`../../runtime/wo-language.md`](../../runtime/wo-language.md) — the user-facing language overview this project builds on
- [`../../runtime/database/02-wo-language.md`](../../runtime/database/02-wo-language.md) — the two-layer language spec (schema + query layers)
- [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) — the `##ui`/`##policy`/`##service`/`##app` block spec
- [`../../../prototypes/wo-db/`](../../../prototypes/wo-db/) — the C++ prototype that runs the query-layer subset today

View file

@ -1,173 +0,0 @@
###############################################################################
# blog/api.rest — exercise the `.wo` runtime against this directory.
#
# Start the server first (from repo root):
# cargo run --bin wo -- run docs/examples/blog
#
# Then in VS Code (REST Client extension) or JetBrains (HTTP Client) click
# "Send Request" on each block top to bottom. `# @name foo` lets later blocks
# pick up ids minted by earlier ones.
#
# A more annotated, side-by-side cousin of this file (with the same requests
# but heavier commentary on Stage-3+ stubs) lives at reference/rest/blog.rest.
###############################################################################
@host = http://127.0.0.1:8080
### Runtime info — 200
GET {{host}}/
### Liveness probe — 200 "ok"
GET {{host}}/healthz
###############################################################################
# Author — exposes: list, get, me, subscribe (no create)
###############################################################################
### List authors — 200 [] on a fresh boot (Stage 2 has no startup seeding)
GET {{host}}/api/authors
### Author create not exposed — 405
POST {{host}}/api/authors
Content-Type: application/json
{ "email": "alice@example.com", "handle": "alice", "display": "Alice" }
### /me — Stage 3 session layer; 501
GET {{host}}/api/authors/me
### LIVE subscribe — Stage 3; 501
GET {{host}}/api/authors/live
###############################################################################
# Article — exposes: list, get, create, update, delete, subscribe
###############################################################################
### Create an article — 201
# @name createArticle
POST {{host}}/api/articles
Content-Type: application/json
{
"slug": "hello-writeonce",
"title": "Hello, writeonce",
"author": 1,
"published": true,
"meta": {
"excerpt": "First post on the new runtime.",
"body_md": "# Hi\n\nHello from the `.wo` runtime. The server, the database, and this HTTP API are all one binary.\n"
}
}
### Create a draft — 201
# @name createDraft
POST {{host}}/api/articles
Content-Type: application/json
{
"slug": "second-draft",
"title": "Second Post (draft)",
"author": 1,
"published": false,
"meta": { "excerpt": "", "body_md": "WIP." }
}
### List articles — 200 with 2 rows
GET {{host}}/api/articles
### Get one article — 200
GET {{host}}/api/articles/{{createArticle.response.body.id}}
### PATCH the title — 200
PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
Content-Type: application/json
{ "title": "Hi, writeonce!" }
### PATCH an embedded-doc field (Stage 2 = shallow merge — re-send the whole `meta`)
PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
Content-Type: application/json
{
"meta": { "excerpt": "Updated excerpt.", "body_md": "# Hi\n\nUpdated body." }
}
### Publish the draft — 200 (`on update` trigger that sets published_at is Stage 3+)
PATCH {{host}}/api/articles/{{createDraft.response.body.id}}
Content-Type: application/json
{ "published": true }
### Delete the draft — 204
DELETE {{host}}/api/articles/{{createDraft.response.body.id}}
### Re-fetch the deleted id — 404
GET {{host}}/api/articles/{{createDraft.response.body.id}}
### LIVE subscribe — Stage 3; 501
GET {{host}}/api/articles/live
###############################################################################
# Tag — exposes: list, get, subscribe
###############################################################################
### List tags — 200 []
GET {{host}}/api/tags
### Tag create not exposed — 405
POST {{host}}/api/tags
Content-Type: application/json
{ "slug": "rust", "label": "Rust" }
### LIVE subscribe — Stage 3; 501
GET {{host}}/api/tags/live
###############################################################################
# Comment — exposes: list, get, create, update, delete, subscribe
###############################################################################
### Create a comment — 201
# @name createComment
POST {{host}}/api/comments
Content-Type: application/json
{
"article": {{createArticle.response.body.id}},
"author": 1,
"body": "Nice post. Runs on one binary which is still weird to me."
}
### List comments — 200 with 1 row
GET {{host}}/api/comments
### Get one comment — 200
GET {{host}}/api/comments/{{createComment.response.body.id}}
### Update body — 200 (the `set self.edited_at = now()` trigger lands in Stage 3+)
PATCH {{host}}/api/comments/{{createComment.response.body.id}}
Content-Type: application/json
{ "body": "Edited: really, one binary? Neat." }
### Delete the comment — 204
DELETE {{host}}/api/comments/{{createComment.response.body.id}}
### LIVE subscribe — Stage 3; 501
GET {{host}}/api/comments/live
###############################################################################
# Final state — one updated article, no drafts, no comments.
###############################################################################
### Final article list — 200 with 1 row
GET {{host}}/api/articles
### Final comment list — 200 []
GET {{host}}/api/comments

View file

@ -1,48 +0,0 @@
-- Root manifest. Names the app, maps URL paths to UI screens, and configures
-- project-wide concerns (theme, i18n). The compiler uses this to assemble the
-- static route table and the SSR renderer.
##app
name: "blog"
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:
/ -> ui.article-list
/article/:slug -> ui.article-detail { key: $slug }
/tag/:slug -> ui.article-list { filter: { tags.slug == $slug } }
/admin -> ui.article-list { role: Admin }
-- Project-wide policies — applied on every query, AND-ed with any type-level
-- policy. Useful for ops-level toggles like admin bypass.
policy admin-bypass
applies_to: Article, Comment, Author, Tag
when: $session.role == Admin
effect: skip-row-filters
-- Lifecycle hooks. `on startup` runs once before the HTTP server binds;
-- convenient for idempotent seeding.
on startup
do: seed_admin()
-- Inline function — available to triggers and lifecycle hooks.
fn seed_admin() {
if count(Author{ role == Admin }) == 0 {
insert Author {
email: "admin@example.com",
handle: "admin",
display: "Admin",
role: Admin
};
}
}

View file

@ -1,83 +0,0 @@
-- Tests live under tests/ and are picked up by `wo test`.
-- Each `test` block runs in an isolated database sandbox that's rolled back
-- at the end of the block — no cleanup code needed.
test "create and fetch by slug" {
let a = insert Author {
email: "alice@example.com",
handle: "alice",
display: "Alice",
role: Author
};
let art = insert Article {
slug: "hello",
title: "Hello, writeonce",
author: a,
meta: {
excerpt: "A first post.",
body_md: "# Hello\n\nThis is writeonce."
},
published: true
};
let fetched = select Article{ slug == "hello" };
assert fetched.id == art.id;
assert fetched.title == "Hello, writeonce";
assert fetched.published_at != null; -- set by the on-update trigger
assert fetched.word_count > 0; -- computed field
}
test "policy blocks public read of unpublished drafts" {
let a = insert Author { email: "bob@example.com", handle: "bob", display: "Bob", role: Author };
insert Article {
slug: "draft", title: "Draft", author: a,
meta: { excerpt: "", body_md: "" },
published: false
};
-- Anonymous session: the `anyone when published == true` policy applies.
as session anonymous {
let rows = select Article{ slug == "draft" };
assert len(rows) == 0;
}
-- As the author, the row is visible.
as session $a {
let rows = select Article{ slug == "draft" };
assert len(rows) == 1;
}
}
test "graph traversal: related articles" {
let a = insert Author { email: "c@x", handle: "carol", display: "Carol", role: Author };
let a1 = insert Article { slug: "one", title: "One", author: a, meta: { excerpt: "", body_md: "" }, published: true };
let a2 = insert Article { slug: "two", title: "Two", author: a, meta: { excerpt: "", body_md: "" }, published: true };
let a3 = insert Article { slug: "three", title: "Three", author: a, meta: { excerpt: "", body_md: "" }, published: true };
-- Create :RELATED_TO edges: one → two, one → three
link Article{ slug == "one" } -[:RELATED_TO]-> Article{ slug == "two" };
link Article{ slug == "one" } -[:RELATED_TO]-> Article{ slug == "three" };
let related = Article{ slug == "one" }.related;
assert len(related) == 2;
assert contains(related, slug == "two");
assert contains(related, slug == "three");
}
test "live subscription receives delta on commit" {
let sub = subscribe live Article{ published == true };
let a = insert Author { email: "d@x", handle: "dave", display: "Dave", role: Author };
insert Article {
slug: "live-test", title: "Live", author: a,
meta: { excerpt: "", body_md: "" },
published: true
};
-- receive(sub) blocks until the next delta or the test-default timeout.
let delta = receive(sub);
assert delta.kind == Insert;
assert delta.row.slug == "live-test";
}

View file

@ -1,68 +0,0 @@
-- The heart of the app. Article combines all three paradigms in one type:
-- * relational scalars (slug, title, published, published_at)
-- * an embedded document (meta.body_md, meta.excerpt, meta.hero_image)
-- * graph edges (tags, related, prerequisites)
-- The compiler picks storage per field: scalars → relational row, meta → doc,
-- multi/ref → graph edges + FK columns.
type Article {
id: Id
slug: Slug @unique -- URL path component
title: Text
author: ref Author -- foreign key into Author
published: Bool = false
published_at: Timestamp? -- set by the trigger below
created_at: Timestamp = now()
updated_at: Timestamp = now()
-- Embedded document. All fields stored together; no join on read.
meta: {
excerpt: Text
body_md: Markdown
hero_image: Url?
reading_min: Int? -- computed by a pre-commit hook
}
-- Tags: many-to-many graph edge with a label, no edge properties.
tags: multi Tag @edge(:TAGGED_AS)
-- "You might also like" — directed graph edge between articles.
related: multi Article @edge(:RELATED_TO)
-- Prerequisite reading, also a directed edge; distinct label so queries
-- can traverse tags vs prereqs independently.
prerequisites: multi Article @edge(:PREREQUISITE)
-- Inverse of Comment.article. Read-only from this side.
comments: backlink Comment.article
-- Computed: word count, derived from the markdown body.
word_count: Int = words(meta.body_md)
-- Policies — row-level access rules. The planner AND-s them into every
-- query so they can't be bypassed by a poorly-scoped handler.
policy read anyone when published == true
policy read for role Admin
policy read for role Author when author == $session.user
policy write for role Admin
policy write for role Author when author == $session.user
policy delete for role Admin
-- Pre-commit trigger: set published_at when the article is first published.
-- Fires inside the transaction, so the set is atomic with the update.
on update
when old.published == false and new.published == true
do set self.published_at = now()
do emit "article.published"(self)
do enqueue "send-subscriber-emails" with { article_id: self.id }
-- Bump updated_at on every mutation — except the insert, where created_at
-- already covers it.
on update
do set self.updated_at = now()
-- REST surface. `subscribe` is the LIVE query endpoint — WebSocket that
-- pushes a delta on every commit matching the predicate.
service rest "/api/articles"
expose list, get, create, update, delete, subscribe
}

View file

@ -1,26 +0,0 @@
-- Authors write articles. Keeping the type small: one author is one writer
-- with a session-bindable identity (role == "author" or "admin").
type Author {
id: Id
email: Email @unique -- primary identity; uniqueness is planner-enforced
handle: Slug @unique -- URL-friendly (e.g. "alice")
display: Text
bio: Markdown? -- optional
avatar: Url?
joined_at: Timestamp = now()
role: Reader | Author | Admin = Reader -- tagged union, stored as enum
-- Inverse link: computed from Article.author. No storage column; resolved at query time.
articles: backlink Article.author
-- Policies are expressed next to the type; the planner AND-s them into every query.
policy read anyone
policy write for role Admin
policy write for role Author when self == $session.user -- authors can edit their own profile
-- Expose a small REST surface. `me` is a virtual endpoint the runtime wires
-- to the current session user.
service rest "/api/authors"
expose list, get, me, subscribe
}

View file

@ -1,31 +0,0 @@
-- Reader comments on an article. Kept small for the sample, but demonstrates
-- cascading policies (a reader can only edit their own comment) and two-way
-- live subscription (both articles and comments push deltas).
type Comment {
id: Id
article: ref Article
author: ref Author
body: Markdown
created_at: Timestamp = now()
edited_at: Timestamp?
-- Anyone can read a comment on a visible article. The engine threads the
-- Article.read policy through this relationship automatically because of
-- the `ref Article` above.
policy read anyone
-- Writing is by role; the owner-check is the row-level rule.
policy write for role Author when author == $session.user
policy write for role Admin
policy delete for role Author when author == $session.user
policy delete for role Admin
-- Stamp edited_at whenever the body changes post-insert.
on update
when old.body != new.body
do set self.edited_at = now()
service rest "/api/comments"
expose list, get, create, update, delete, subscribe
}

View file

@ -1,20 +0,0 @@
-- Tags are a small taxonomy. A separate type (rather than a string array on
-- Article) because we want to query by tag cheaply AND attach per-tag metadata
-- (colour, description) later without a migration that rewrites articles.
type Tag {
id: Id
slug: Slug @unique
label: Text
description: Markdown?
colour: Text = "#888"
-- Computed field: count of articles with this tag.
-- Re-evaluated on read; if it gets hot, flip to `@materialized` to cache.
article_count: Int = count(Article{ tags contains self })
policy read anyone
policy write for role Admin
service rest "/api/tags" expose list, get, subscribe
}

View file

@ -1,33 +0,0 @@
-- Detail page: one article, its body, its comments, and a "related" section
-- that traverses the :RELATED_TO graph edge. Every section can be live-bound.
##ui
#article-detail
title: $article.title
source: Article
key: slug
live: true
sections:
- header:
fields: [title, author.display, published_at, tags]
- body:
renderer: markdown
source: meta.body_md
-- Graph traversal: one hop along :RELATED_TO, rendered inline.
- related:
title: "You might also like"
renderer: list
source: Article{ slug == $key }.related
columns: [title, meta.excerpt, published_at]
live: true
-- 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:
use: article-comments
with:
article-id: $article.id

View file

@ -1,28 +0,0 @@
-- Home page: list of published articles, newest first. The `live: true` flag
-- makes the runtime auto-register a LIVE query matching the displayed columns;
-- the rendered HTML ships with a small client that binds deltas to the DOM.
##ui
#article-list
title: "Articles"
source: Article
live: true
filter:
published == true
columns:
- slug label: "Slug" renderer: code
- title label: "Title" searchable
- author.display label: "Author"
- published_at label: "Published" renderer: relative-date
- tags label: "Tags" renderer: tag-chips
sort:
default: published_at desc
actions:
row-click: /article/:slug
create: /article/new role: Author | Admin
pagination: 20

View file

@ -1,15 +0,0 @@
/* 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

@ -1,22 +0,0 @@
<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

@ -1,11 +0,0 @@
-- 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

@ -1,19 +0,0 @@
/* 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

@ -1,25 +0,0 @@
<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

@ -1,23 +0,0 @@
-- 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

@ -1,31 +0,0 @@
# Project manifest — the `wo.toml` is the .wo equivalent of Go's `go.mod`.
# `wo run` and `wo build` both start by reading this file.
name = "blog"
version = "0.1.0"
description = "A sample writeonce blogging app: DB + REST + live subscriptions in ~200 lines of .wo"
# Runtime constraint — which wo toolchain this project targets.
[runtime]
wo = ">= 0.1"
# HTTP server configuration.
# Endpoints come from `service rest` blocks on types + routes in app.wo.
[server]
listen = ":8080"
# Database configuration. For this sample the engine persists to ./data/
# via the WAL + in-memory engine from Phase 3.
[database]
data_dir = "./data"
isolation = "snapshot" # default isolation for transactions
# External .wo modules would go here, mirroring `go.mod`'s require block.
# Locked versions land in `wo.lock` (not shown — auto-generated).
[dependencies]
# wo-stdlib = ">= 0.1" # implicit, always imported
# Commands `wo test` picks up. Each test file lives under tests/ and
# matches `*_test.wo`.
[test]
parallel = true

View file

@ -1,116 +0,0 @@
# `ecommerce` — a sample writeonce **monorepo**
Two apps (customer **storefront** + ops **admin**) sharing one database, built from a common pool of types + business logic. Mirrors the Nx / Angular workspace pattern: `apps/*` for deployable binaries, `shared/*` for libraries imported across apps.
> This project is a **docs artifact** — illustrative `.wo` source showing what a production-shaped writeonce workspace looks like. The master plan for the compiler + client runtime + per-app build is at [`../../plan/ui/00-overview.md`](../../plan/exploration/ui/00-overview.md). Sub-phases UI/01–07 implement each piece.
## Layout
```
ecommerce/
├── wo.toml # workspace manifest (apps[] + shared[] + database)
├── README.md # this file
│
├── shared/ # code imported by one or more apps
│ ├── types/
│ │ ├── customer.wo # role union, policy, service rest expose
│ │ ├── product.wo # inventory + similar_to graph
│ │ ├── order.wo # tagged-union status + line_items array
│ │ └── purchase.wo # link Customer -> Product
│ ├── logic/
│ │ ├── checkout.wo # fn checkout / mark_paid / mark_shipped
│ │ └── seed.wo # on-startup demo seed + admin-ops bypass policy
│ └── components/ # reusable .htmlx partials (forward-looking — UI track)
│ ├── layout.htmlx # page chrome shared across apps
│ ├── money.htmlx # {{> money amount=total}}
│ └── order-row.htmlx # used by both orders tables
│
├── apps/ # one binary per app
│ ├── storefront/ # customer-facing
│ │ ├── wo.toml # listen :8080, connect WO_DB
│ │ ├── app.wo # routes: / → home, /product/:sku, /cart, /orders
│ │ └── ui/
│ │ ├── home/
│ │ │ └── home.wo # product list, live inventory
│ │ ├── product-detail/ # (future)
│ │ └── orders/
│ │ └── orders.wo # customer's own orders
│ │
│ └── admin/ # ops dashboard
│ ├── wo.toml # listen :8081, connect WO_DB
│ ├── app.wo # role: Admin | Ops; routes: /orders
│ └── ui/
│ └── orders/
│ └── orders.wo # live ops table with fulfillment actions
│
└── tests/ # workspace-level integration
└── checkout_test.wo
```
Each app's UI screens live in their own directory (`apps/<app>/ui/<screen>/`) with the Angular-style one-directory-per-component pattern — `.wo` declarative spec today, `.htmlx` template + `.css` stylesheet once the UI track's sub-phase 01–02 land.
## What the two apps share
- **Types** (`shared/types/`). Both apps see the same `Customer` / `Product` / `Order` / `Purchase` definitions. Row-level policies inside each `type` block control who sees what — the storefront's authenticated customer sees their own orders; the admin app's Admin/Ops role sees everyone's.
- **Logic** (`shared/logic/`). The `checkout`, `mark_paid`, `mark_shipped`, and `release_inventory` functions in `shared/logic/checkout.wo` are callable from either app (subject to role policy). `shared/logic/seed.wo` runs once when the shared DB daemon starts.
- **Components** (`shared/components/`). `.htmlx` partials — layout chrome, money formatting, an order-row renderer — reusable from either app's templates.
## What's **not** shared (per-app)
- **`app.wo`** declares app-specific routes + role gate. Storefront has no `/admin/*` routes; admin has no `/cart` or `/product/:sku`.
- **`ui/`** is per-app. The storefront's `orders/orders.wo` (customer's own orders, filtered by session) and the admin's `orders/orders.wo` (all orders with fulfillment actions) are different screens — same underlying `Order` type, different UI + policy scope.
- Each app's `wo.toml` names its own listen port and its own DB API key.
## Running it
```bash
# 1. Start the shared DB daemon — headless, just the engine + wire protocol
wo db serve --data-dir ./data # listens on wo://127.0.0.1:5555
# 2. Start each app, pointing at the daemon
WO_DB=wo://127.0.0.1:5555 \
STOREFRONT_DB_KEY=$ADMIN_TOKEN \
wo run apps/storefront # HTTP on :8080
WO_DB=wo://127.0.0.1:5555 \
ADMIN_DB_KEY=$ADMIN_TOKEN \
wo run apps/admin # HTTP on :8081
```
Browser:
- `http://localhost:8080/` → storefront home (product list, live inventory)
- `http://localhost:8081/orders` → admin live orders table
## Building binaries
```bash
wo build apps/storefront # → target/wo/storefront
wo build apps/admin # → target/wo/admin
wo build --all # everything in apps/
```
Each binary is a static ELF with only the app's own `app.wo` + `ui/` + the `shared/` it imports baked in. Drop any binary on a server next to a running `wo db serve` and it works.
## Stage 2 caveat
The current runtime at [`crates/rt/`](../../../crates/rt/) is a single-process Stage 2 prototype — it doesn't yet know about:
- `[workspace]` manifests (per-app build — UI sub-phase 05)
- The `wo db serve` daemon split (UI sub-phase 06)
- Per-app `api_key_env` authorisation scope (UI sub-phase 07)
- `.htmlx` compilation from `##ui` blocks (UI sub-phases 01–03)
- Per-app policy composition (UI sub-phase 07)
So `cargo run --bin wo -- run docs/examples/ecommerce` today walks the whole tree, finds every `.wo` file under `shared/` + `apps/`, parses the types, and serves the union REST API on :8080 — treating the monorepo as one giant app. Useful for exercising the types; not reflective of the production shape. See [`docs/plan/ui/00-overview.md`](../../plan/exploration/ui/00-overview.md) for the sub-phase sequence that gets each piece online.
## Comparison with the blog sample
The [`blog` sample](../blog/) is still a single-app layout (`types/`, `ui/`, `logic/` at the root) because the blog has exactly one front-end surface — there's no customer-vs-admin split. Both patterns are first-class; pick based on whether your schema serves one app or many. A flat single-app layout is a degenerate workspace with one `apps/` entry.
## Source pointers
- **Master plan:** [`../../plan/ui/00-overview.md`](../../plan/exploration/ui/00-overview.md)
- **Language spec the `##ui`/`##app`/`policy` blocks obey:** [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md)
- **Wire protocol the app binaries speak to the DB daemon:** [`../../runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md)
- **v1 template engine that `.htmlx` compilation will reuse:** [`../../../.dev/reference/crates/wo-htmlx/`](../../../.dev/reference/crates/wo-htmlx/)
- **Checkout transaction that's the canonical cross-paradigm test:** [`shared/logic/checkout.wo`](shared/logic/checkout.wo)

View file

@ -1,122 +0,0 @@
###############################################################################
# ecommerce/api.rest — exercise the `.wo` runtime against this directory.
#
# Start the server first (from repo root):
# cargo run --bin wo -- run docs/examples/ecommerce
#
# This sample leans on features that land in later stages:
# * orders are minted by `fn checkout(...)` — Stage 3/4
# * customers/products/orders are seeded by `on startup do: seed()` — Stage 3+
# * Order-status lifecycle triggers — Stage 3+
#
# So this file documents what Stage 2 *does* serve: route wiring, empty-list
# reads, 405 for un-exposed methods, and the Stage-3 stubs that respond 501.
# A heavier annotated cousin lives at reference/rest/ecommerce.rest.
###############################################################################
@host = http://127.0.0.1:8080
### Runtime info — 200
GET {{host}}/
### Liveness probe — 200 "ok"
GET {{host}}/healthz
###############################################################################
# Product — exposes: list, get, subscribe (no create — admin seeds inventory)
###############################################################################
### List products — 200 [] (no startup seed yet)
GET {{host}}/api/products
### Product create not exposed — 405
POST {{host}}/api/products
Content-Type: application/json
{
"sku": "SKU-WIDGET",
"name": "Widget",
"price": 1999,
"meta": { "description": "A widget.", "images": [], "attributes": { "colour": "blue" } },
"inventory": { "on_hand": 50, "reserved": 0, "reorder_at": 10 }
}
### Get by id — 404 (nothing exists)
GET {{host}}/api/products/1
### LIVE subscribe — Stage 3; 501
GET {{host}}/api/products/live
###############################################################################
# Customer — exposes: get, me, update, subscribe (no list, no create)
###############################################################################
### List not exposed — 404 (no route at /api/customers at all)
GET {{host}}/api/customers
### Customer create not exposed — 404 (same reason)
POST {{host}}/api/customers
Content-Type: application/json
{ "email": "carol@shop.test", "name": "Carol", "role": "Customer" }
### Get by id — 404 (nothing exists)
GET {{host}}/api/customers/1
### Update by id — 404 (would be 200 if the row existed)
PATCH {{host}}/api/customers/1
Content-Type: application/json
{ "name": "Carol Updated" }
### /me — Stage 3 session layer; 501
GET {{host}}/api/customers/me
### LIVE subscribe — Stage 3; 501
GET {{host}}/api/customers/live
###############################################################################
# Order — exposes: list, get, subscribe (use fn checkout to create)
###############################################################################
### List orders — 200 []
GET {{host}}/api/orders
### Order create not exposed (use fn checkout) — 405
POST {{host}}/api/orders
Content-Type: application/json
{ "customer": 1, "status": "Pending", "line_items": [] }
### LIVE subscribe — the WebSocket the Stage-6 `##ui #admin-orders` board
### will open. Stage 2 returns 501.
GET {{host}}/api/orders/live
###############################################################################
# fn checkout — Stage 3/4 (transactional functions)
#
# Becomes the canonical cross-paradigm ACID test once it lands: one call
# updates the product's inventory doc, inserts an Order row, and creates a
# Purchase graph edge inside one BEGIN ... COMMIT.
# See docs/examples/ecommerce/logic/checkout.wo.
###############################################################################
### Stage 2 — 404 (route not registered yet)
POST {{host}}/api/fn/checkout
Content-Type: application/json
{ "customer": 1, "product": 1, "qty": 2 }
###############################################################################
# Purchase — link type, no `service rest` block
# Edges are created by fn checkout and traversed via Customer.purchased.
###############################################################################
### Purchase list not exposed — 404
GET {{host}}/api/purchases

View file

@ -1,19 +0,0 @@
-- Admin app manifest: the ops/fulfillment dashboard binary.
-- Entire app is gated to role `Admin | Ops`; the storefront's public routes
-- don't live here at all. The cross-entity "admin/ops bypass row filters"
-- policy is global (shared/logic/seed.wo) — this app inherits it via the
-- shared DB connection.
##app
name: "admin"
version: 1
theme: "dark"
i18n: [en]
role: Admin | Ops -- app-level gate
routes:
/orders -> ui.orders -- apps/admin/ui/orders/
-- future:
-- /inventory -> ui.inventory
-- /customers -> ui.customers
-- /reports -> ui.reports

View file

@ -1,60 +0,0 @@
-- THE live order-ops table. This is what an ops/fulfillment team watches all day.
-- `live: true` + `source: Order` auto-registers a LIVE query matching the columns
-- shown; every commit (checkout, status flip, trigger-updated timestamp) pushes
-- a delta down the WebSocket and the client runtime swaps the row in place.
-- No polling, no refresh button.
--
-- Lives in the admin app; the app's own `app.wo` gates it to role Admin | Ops,
-- so customers never hit this route. Same underlying Order table as the
-- storefront's customer-facing order-tracker — row-level policies decide who
-- sees which rows; delta fanout is cross-app (see docs/plan/ui/00-overview.md
-- § Sub-phase 13).
##ui
#orders
title: "Orders — Live"
source: Order
live: true
role: Admin | Ops -- secondary gate (app-level gate in app.wo)
-- Sidebar filter controls bind to these predicates at render time.
filter:
status != Cancelled -- default view hides cancelled
-- Named predicate chips: operators click these to narrow the view.
quick-filters:
- "Needs payment": status == Pending
- "Ready to ship": status == Paid
- "In transit": status == Shipped
- "Today": placed_at >= today()
- "Above $100": total > 10000 -- minor units (cents)
columns:
- id label: "#" renderer: code
- status renderer: pill -- coloured by enum variant
- customer.name label: "Customer"
- customer.email label: "Email"
- total renderer: money
- len(line_items) label: "Items" computed
- placed_at renderer: relative-date
- paid_at renderer: relative-date
- shipped_at renderer: relative-date
sort:
default: placed_at desc
-- Single-row + bulk actions. Each one calls a `fn` from
-- shared/logic/checkout.wo or an app-local fulfillment fn.
actions:
row-click: /orders/:id
row-mark-paid: mark_paid(self) role: Admin | Ops
row-mark-shipped: mark_shipped(self) role: Admin | Ops
row-cancel: update self set status = Cancelled role: Admin
bulk-export-csv: export_orders_csv role: Admin | Ops
-- Delta behaviour: `instant` swaps cells in place on UPDATE deltas (no fade);
-- new rows slide in at their sort position; deleted rows fade out.
refresh: instant
highlight-new: 2s
pagination: 100

View file

@ -1,24 +0,0 @@
name = "admin"
version = "0.1.0"
description = "Ops/fulfillment dashboard binary. Gated to role Admin | Ops."
app_kind = "app"
[runtime]
wo = ">= 0.1"
[dependencies]
shared = [
"../../shared/types",
"../../shared/logic",
"../../shared/components",
]
[server]
listen = ":8081" # admin runs on a separate port from storefront
[database]
# Same daemon as storefront, different API key → daemon's policy table gives
# this app Admin-level row visibility (per docs/plan/ui/07-per-app-policies.md
# when that phase lands).
url = "wo://127.0.0.1:5555"
api_key_env = "ADMIN_DB_KEY"

View file

@ -1,19 +0,0 @@
-- Storefront app manifest: the customer-facing ecommerce binary.
-- Routes serve to anonymous visitors + authenticated customers;
-- admin/ops users exist as DB rows but have no route in this binary.
--
-- The app binary connects to the shared `wo db` daemon (see
-- ../../wo.toml [database] + apps/storefront/wo.toml WO_DB env var).
-- Row-level policies on shared/types/*.wo scope what each session sees.
##app
name: "storefront"
version: 1
theme: "light"
i18n: [en]
routes:
/ -> ui.home -- apps/storefront/ui/home/
/product/:sku -> ui.product-detail { key: $sku } -- apps/storefront/ui/product-detail/
/cart -> ui.cart
/orders -> ui.orders -- apps/storefront/ui/orders/

View file

@ -1,31 +0,0 @@
-- Storefront home — public product list. Live inventory: when a checkout
-- reserves the last unit, the "In stock" badge flips to "Out of stock" on
-- every open browser without refresh.
--
-- Angular-component-style layout: home/{home.wo, home.htmlx, home.css}.
-- home.wo is the declarative spec; home.htmlx (when added) lets an author
-- hand-tune the template while the compiler still generates the
-- <wo:live> + wo:bind subscription glue. See
-- [docs/plan/ui/00-overview.md](../../../../../plan/ui/00-overview.md).
##ui
#home
title: "Shop"
source: Product
live: true
columns:
- meta.images[0] label: "" renderer: image
- name
- sku renderer: code
- price renderer: money
- available label: "In stock" renderer: stock-badge -- computed on Product
sort:
default: name asc
actions:
row-click: /product/:sku
add-to-cart: add_to_cart(self, 1) role: Customer | Ops | Admin
pagination: 24

View file

@ -1,28 +0,0 @@
-- Customer-facing live order list. Same engine, same wire, same delta stream
-- as the admin table — but the row-level policy on `Order` narrows the source
-- to `customer == $session.user`, so a user only ever sees their own orders.
##ui
#orders
title: "Your Orders"
source: Order{ customer == $session.user }
live: true
columns:
- id label: "Order #" renderer: code
- status renderer: pill
- total renderer: money
- placed_at renderer: relative-date
- shipped_at renderer: relative-date label: "Shipped"
- delivered_at renderer: relative-date label: "Delivered"
sort:
default: placed_at desc
actions:
row-click: /orders/:id
cancel: update self set status = Cancelled when status == Pending
empty-state:
message: "You haven't placed any orders yet."
cta: { label: "Shop", link: "/" }

View file

@ -1,26 +0,0 @@
name = "storefront"
version = "0.1.0"
description = "Customer-facing ecommerce binary. Connects to the shared wo-db daemon."
app_kind = "app" # not a library; produces a binary under target/wo/storefront
[runtime]
wo = ">= 0.1"
# The storefront depends on the shared type + logic sources one workspace
# level up. Paths are workspace-relative.
[dependencies]
shared = [
"../../shared/types",
"../../shared/logic",
"../../shared/components",
]
[server]
listen = ":8080" # what this app exposes to browsers
[database]
# Pointer to the shared DB daemon. At runtime, `WO_DB=wo://...` overrides.
url = "wo://127.0.0.1:5555"
# The app presents this API key on connect; the daemon's per-app policy
# table scopes what rows it can see.
api_key_env = "STOREFRONT_DB_KEY"

View file

@ -1,42 +0,0 @@
{{!--
Top-level page chrome shared across every app in the workspace.
Both storefront and admin inherit this layout and override the `{{> slot.content}}`
partial with their own screen's body.
The `wo:bind="session.user.name"` in the header is a field-level live binding:
when the customer updates their profile, every open tab's header repaints
without a page reload — the same delta-dispatch mechanism the live tables use,
just on a singleton subscription.
See docs/plan/ui/01-htmlx-format-spec.md (to land in a future sub-phase)
for the full grammar.
--}}
<!doctype html>
<html lang="{{app.i18n}}">
<head>
<meta charset="utf-8">
<title>{{page.title}} — {{app.name}}</title>
<link rel="stylesheet" href="/static/{{app.name}}.css">
{{> slot.head}}
</head>
<body class="theme-{{app.theme}}">
<header class="page-header">
<a href="/" class="brand">{{app.name}}</a>
<nav>{{> slot.nav}}</nav>
{{#if session.user}}
<span class="session" wo:bind="session.user.name">{{session.user.name}}</span>
{{/if}}
</header>
<main class="page-body">
{{> slot.content}}
</main>
<footer class="page-footer">
<small>Built with writeonce · {{app.version}}</small>
</footer>
{{!-- Client runtime + subscription manifest are injected automatically. --}}
<script data-wo-runtime src="/_wo/runtime.js"></script>
</body>
</html>

View file

@ -1,6 +0,0 @@
{{!--
Tiny partial: renders a Money value (minor units — cents) as a localised
currency string. Called from any screen via `{{> money amount=total}}`.
See apps/admin/ui/orders/orders.wo columns block for a live example.
--}}
<span class="money">${{divide amount by 100}}.{{mod amount by 100 padleft 2 with "0"}}</span>

View file

@ -1,23 +0,0 @@
{{!--
One order row — used by both apps' order tables. Different columns are
displayed depending on the caller, via conditional sub-partials; the
`wo:bind="status"` attribute makes the status cell live-update on delta
frames regardless of which table embeds this row.
Customer view (storefront/ui/orders/orders.wo): {{> order-row for="customer"}}
Admin view (admin/ui/orders/orders.wo): {{> order-row for="ops"}}
--}}
<tr class="order-row status-{{status}}" data-key="{{id}}">
<td class="order-id" wo:bind="id">{{id}}</td>
<td class="order-status" wo:bind="status">{{status}}</td>
{{#if (eq for "ops")}}
<td class="customer-name">{{customer.name}}</td>
<td class="customer-email">{{customer.email}}</td>
{{/if}}
<td class="order-total" wo:bind="total">{{> money amount=total}}</td>
<td class="order-placed" wo:bind="placed_at">{{relative placed_at}}</td>
{{#if (eq for "ops")}}
<td class="order-paid" wo:bind="paid_at">{{relative paid_at}}</td>
<td class="order-shipped" wo:bind="shipped_at">{{relative shipped_at}}</td>
{{/if}}
</tr>

View file

@ -1,95 +0,0 @@
-- The canonical cross-paradigm transaction from the Phase 2 language spec.
-- `checkout` touches three storage paradigms atomically:
-- 1. relational row — insert into Order
-- 2. document field — decrement Product.inventory (embedded doc)
-- 3. graph edge — create a Purchase edge linking Customer → Product
--
-- The whole function runs inside `txn snapshot` (snapshot isolation). If any
-- step fails, the transaction coordinator rolls back every partial write
-- across all three engines. No partial orders, no phantom inventory drift.
fn checkout(
customer: ref Customer,
product: ref Product,
qty: Int
) -> Order in txn snapshot
{
-- Load the current product row in-transaction (so we see a consistent
-- snapshot for the inventory check).
let p = select Product{ id == product.id };
-- Domain invariant. `otherwise abort` rolls the transaction back.
assert p.available >= qty
otherwise abort "insufficient inventory for " + p.sku;
-- Reserve inventory. Document path update inside the relational Product row.
update Product{ id == p.id }
set inventory.reserved = inventory.reserved + qty;
-- Create the order. `insert ... returning self` binds the inserted row to
-- the let-binding so downstream statements can refer to its id without
-- the legacy `LAST_INSERT_ID()` dance.
let o = insert Order {
customer: customer,
status: Pending,
line_items: [{
product: p,
qty: qty,
unit_price: p.price
}]
};
-- Graph edge: (customer)-[:PURCHASED {order, qty, unit_price}]->(product).
-- References the order id that was minted by the insert above — the
-- transaction-scoped alias table threads `o.id` through to the graph store
-- without a round-trip to the client.
link customer
-[Purchase { order: o, qty: qty, unit_price: p.price }]->
p;
return o;
}
-- Mark an order paid. Called by the payment-webhook handler. The inventory
-- flip (reserved → on_hand-delta) runs atomically with the status change.
fn mark_paid(o: ref Order) in txn snapshot
{
update Order{ id == o.id }
set status = Paid;
-- Draw down on_hand for each line item; clear the reservation.
for line in Order{ id == o.id }.line_items {
update Product{ id == line.product.id }
set inventory.on_hand = inventory.on_hand - line.qty,
inventory.reserved = inventory.reserved - line.qty;
}
}
-- Cancellation or refund: release the inventory reservation.
-- Called from the `on update when ... status == Cancelled` trigger in order.wo.
fn release_inventory(o: ref Order) in txn snapshot
{
for line in o.line_items {
if o.status == Paid or o.status == Shipped or o.status == Delivered {
-- Already decremented on_hand; put it back.
update Product{ id == line.product.id }
set inventory.on_hand = inventory.on_hand + line.qty;
} else {
-- Still reserved; release the reservation.
update Product{ id == line.product.id }
set inventory.reserved = inventory.reserved - line.qty;
}
}
}
-- Lifecycle advance: ship an order. Ops triggers this from the admin UI.
fn mark_shipped(o: ref Order) in txn snapshot
{
assert o.status == Paid
otherwise abort "can only ship paid orders (current: " + o.status + ")";
update Order{ id == o.id }
set status = Shipped;
}

View file

@ -1,51 +0,0 @@
-- Idempotent demo seed + the cross-entity Admin/Ops bypass policy.
-- Both belong to the *shared* layer rather than to any one app:
-- * The seed primes the shared database so either the storefront or the
-- admin app shows something on first boot.
-- * The bypass policy applies across every type, so it has to live where
-- every app can see it.
-- Runs once when the shared `wo db serve` daemon comes up — each app connects
-- over the wire and inherits the already-seeded state.
policy admin-ops-bypass
applies_to: Order, Product, Customer, Purchase
when: $session.role == Admin or $session.role == Ops
effect: skip-row-filters
on startup
do: seed()
fn seed() {
if count(Customer{ email == "admin@shop.test" }) == 0 {
insert Customer {
email: "admin@shop.test",
name: "Ops Admin",
role: Admin
};
}
if count(Product{ sku == "SKU-WIDGET" }) == 0 {
insert Product {
sku: "SKU-WIDGET",
name: "Widget",
price: 1999,
meta: {
description: "A classic widget.",
images: ["/static/widget.jpg"],
attributes: { colour: "blue", size: "M", material: "steel" }
},
inventory: { on_hand: 50, reserved: 0, reorder_at: 10 }
};
insert Product {
sku: "SKU-GIZMO",
name: "Gizmo",
price: 4999,
meta: {
description: "A premium gizmo.",
images: ["/static/gizmo.jpg"],
attributes: { colour: "black", size: "L", material: "aluminium" }
},
inventory: { on_hand: 12, reserved: 0, reorder_at: 3 }
};
}
}

View file

@ -1,33 +0,0 @@
-- Customer is both the public-facing shopper and (via role) the ops/admin
-- identity. Roles gate who sees the /admin/orders live table.
type Customer {
id: Id
email: Email @unique
name: Text
role: Guest | Customer | Ops | Admin = Customer
addr: {
line1: Text
city: Text
postal: Text
country: Text
}? -- optional shipping address
joined_at: Timestamp = now()
-- Graph edge with properties: one PURCHASED edge per line item per order.
-- The `Purchase` link type lives in types/purchase.wo.
purchased: multi Product via Purchase
-- Inverse of Order.customer — read-only, no storage column.
orders: backlink Order.customer
policy read for role Admin
policy read for role Ops
policy read when self == $session.user
policy write for role Admin
policy write when self == $session.user
service rest "/api/customers"
expose get, me, update, subscribe
}

View file

@ -1,59 +0,0 @@
-- The Order type shows three advanced schema-layer features working together:
-- * a tagged union for `status` (compiled to an enum column)
-- * an array of inline structs for `line_items` (stored as a doc column)
-- * a computed `total` field aggregating over the array
-- Status transitions each fire their own `on update` trigger — the timestamp
-- columns (paid_at, shipped_at, …) are set by those triggers, not the caller.
type Order {
id: Id
customer: ref Customer
status: Pending | Paid | Shipped | Delivered | Refunded | Cancelled = Pending
-- Array of embedded line-item objects. `product: ref Product` lets the
-- planner enforce FK integrity even inside the doc column.
line_items: [{
product: ref Product
qty: Int @check(> 0)
unit_price: Int -- captured at checkout time
}]
-- Computed: re-evaluated on read. Flip to `@materialized` if it gets hot.
total: Int = sum(line_items.*.qty * line_items.*.unit_price)
placed_at: Timestamp = now()
paid_at: Timestamp?
shipped_at: Timestamp?
delivered_at: Timestamp?
cancelled_at: Timestamp?
-- Customers see their own orders. Ops + Admin see all.
policy read for role Admin
policy read for role Ops
policy read when customer == $session.user
policy write for role Admin
policy write for role Ops
-- Lifecycle triggers. Each fires inside the committing transaction — the
-- timestamp update is atomic with the status change, never observable half-way.
on update when old.status != Paid and new.status == Paid
do set self.paid_at = now()
do emit "order.paid"(self)
do enqueue "fulfill" with { order_id: self.id }
on update when old.status != Shipped and new.status == Shipped
do set self.shipped_at = now()
do emit "order.shipped"(self)
on update when old.status != Delivered and new.status == Delivered
do set self.delivered_at = now()
on update when old.status != Cancelled and new.status == Cancelled
do set self.cancelled_at = now()
do call release_inventory(self) -- defined in logic/checkout.wo
service rest "/api/orders"
expose list, get, subscribe
}

View file

@ -1,45 +0,0 @@
-- Product combines relational scalars (sku, price), an embedded document
-- (meta: description/images/attributes), and a graph edge (similar_to).
-- Inventory is an embedded doc so a checkout atomically updates it together
-- with the order row inside one transaction.
type Product {
id: Id
sku: Text @unique
name: Text
price: Int -- minor units (cents)
meta: { -- embedded document
description: Markdown
images: [Url]
attributes: { colour: Text?, size: Text?, material: Text? }
}
inventory: { -- embedded document with constraints
on_hand: Int = 0 @check(>= 0) -- total units physically in stock
reserved: Int = 0 @check(>= 0) -- units held by pending orders
reorder_at: Int = 5
}
-- Computed read-only field: the inventory the storefront actually shows.
available: Int = inventory.on_hand - inventory.reserved
in_stock: Bool = available > 0
-- Graph edge — recommendation surface used by `/product/:sku` pages.
similar_to: multi Product @edge(:SIMILAR_TO)
policy read anyone
policy write for role Admin
policy write for role Ops
-- Pre-commit trigger: fire a reorder job when inventory crosses the threshold.
-- `old` and `new` reference the row state before and after the current txn.
on update
when new.inventory.on_hand <= new.inventory.reorder_at
and old.inventory.on_hand > new.inventory.reorder_at
do emit "inventory.low"(self)
do enqueue "reorder" with { product_id: self.id, current: new.inventory.on_hand }
service rest "/api/products"
expose list, get, subscribe
}

View file

@ -1,20 +0,0 @@
-- Graph edge **with properties**: one record per line item per order.
-- The `link Customer -> Product` form tells the compiler this is a directed
-- graph edge type, stored in the graph engine but queryable in both directions
-- (via Customer.purchased and the inverse relation).
--
-- Phase 2 calls this "link-with-properties" — it's the feature that makes
-- graph recommendations queryable alongside relational order data without
-- stitching two stores.
type Purchase link Customer -> Product {
order: ref Order -- FK so you can query
-- `Customer.purchased{ order.status == Paid }`
qty: Int @check(> 0)
unit_price: Int -- captured at checkout time
at: Timestamp = now()
policy read for role Admin
policy read for role Ops
policy read when source == $session.user -- `source` = the edge's from-node
}

View file

@ -1,95 +0,0 @@
-- Three tests covering the cross-paradigm checkout and the live ops table.
-- Each `test` runs against an isolated snapshot that rolls back at the end.
test "checkout atomically reserves inventory, creates order, and creates graph edge" {
let c = insert Customer { email: "alice@shop.test", name: "Alice" };
let p = insert Product {
sku: "T-1", name: "T1", price: 999,
meta: { description: "", images: [], attributes: {} },
inventory: { on_hand: 10, reserved: 0, reorder_at: 2 }
};
let o = checkout(c, p, 3);
-- relational: order exists with the right shape
assert o.status == Pending;
assert o.total == 2997;
assert len(o.line_items) == 1;
assert o.line_items[0].qty == 3;
-- document: inventory reserved, on_hand untouched (reservation only)
let refetched = select Product{ sku == "T-1" };
assert refetched.inventory.reserved == 3;
assert refetched.inventory.on_hand == 10;
assert refetched.available == 7;
-- graph: Purchase edge threads the new order id into the graph store
let edges = Customer{ id == c.id }.purchased;
assert len(edges) == 1;
assert edges[0].target.sku == "T-1";
assert edges[0].qty == 3;
assert edges[0].order == o.id; -- cross-paradigm ref
}
test "checkout aborts without partial state when inventory is insufficient" {
let c = insert Customer { email: "bob@shop.test", name: "Bob" };
let p = insert Product {
sku: "T-2", name: "T2", price: 499,
meta: { description: "", images: [], attributes: {} },
inventory: { on_hand: 2, reserved: 0, reorder_at: 0 }
};
expect_abort "insufficient" {
checkout(c, p, 5);
}
-- nothing was written: no order, no reservation, no edge
let still = select Product{ sku == "T-2" };
assert still.inventory.reserved == 0;
assert len(select Order{ customer == c.id }) == 0;
assert len(Customer{ id == c.id }.purchased) == 0;
}
test "admin live-orders subscription receives deltas across the order lifecycle" {
let c = insert Customer { email: "carol@shop.test", name: "Carol" };
let p = insert Product {
sku: "T-3", name: "T3", price: 1500,
meta: { description: "", images: [], attributes: {} },
inventory: { on_hand: 5, reserved: 0, reorder_at: 0 }
};
-- Subscribe with the same predicate the admin UI uses.
let sub = subscribe live Order{ status != Cancelled };
-- Snapshot first (zero rows — test starts clean).
let snap = receive(sub);
assert snap.kind == Snapshot;
assert len(snap.rows) == 0;
-- Checkout → expect an Insert delta (status Pending, total set).
let o = checkout(c, p, 1);
let d1 = receive(sub);
assert d1.kind == Insert;
assert d1.row.id == o.id;
assert d1.row.status == Pending;
assert d1.row.total == 1500;
-- mark_paid flips status → expect an Update delta. paid_at is set by the
-- type-attached `on update` trigger inside the same transaction, so it
-- arrives in the same delta — never observable half-way.
mark_paid(o);
let d2 = receive(sub);
assert d2.kind == Update;
assert d2.row.status == Paid;
assert d2.row.paid_at != null;
-- mark_shipped → Update with shipped_at set.
mark_shipped(o);
let d3 = receive(sub);
assert d3.kind == Update;
assert d3.row.status == Shipped;
assert d3.row.shipped_at != null;
}

View file

@ -1,32 +0,0 @@
name = "ecommerce-workspace"
version = "0.1.0"
description = "Monorepo: shared types + logic powering two apps (storefront, admin) against one DB"
kind = "workspace" # not a single app — see [workspace] below
[runtime]
wo = ">= 0.1"
# Apps = one binary each; shared = libraries imported across apps by path.
# Mirrors Angular/Nx workspaces (apps/* + libs/*). See
# docs/plan/ui/00-overview.md § Target layout.
[workspace]
apps = [
"apps/storefront",
"apps/admin",
]
shared = [
"shared/types",
"shared/logic",
"shared/components",
]
# The shared DB daemon — one process, no UI, just the engine + wire protocol.
# Starts with `wo db serve` (see docs/plan/ui/06-shared-db-daemon.md when
# that sub-phase lands). Apps connect at runtime via WO_DB.
[database]
listen = "127.0.0.1:5555" # native wire-protocol port
data_dir = "./data"
isolation = "snapshot" # default for cross-paradigm checkout
[test]
parallel = false # cross-app integration tests hit the same DB

View file

@ -1,71 +0,0 @@
-- main.wo — the smallest complete writeonce program.
--
-- Run it from the repo root:
--
-- cargo run --bin wo -- run docs/examples/hello (or: just hello)
-- just hello-demo -- scripted CRUD round-trip
--
-- and exercise it:
--
-- curl -X POST localhost:8080/api/notes \
-- -H "Content-Type: application/json" \
-- -d '{"title":"hello","body":"# First note"}'
-- curl localhost:8080/api/notes
-- curl localhost:8080/api/notes/1
-- curl -X PATCH localhost:8080/api/notes/1 -d '{"pinned":true}'
-- curl -X DELETE localhost:8080/api/notes/1
--
-- One type declaration is the whole app: the fields define the schema, the
-- `service` block generates the REST endpoints, and the runtime serves them
-- from a single binary — no external database, no framework.
type Note {
id: Id
title: Text
body: Markdown
pinned: Bool = false -- literal defaults populate on create
created_at: Timestamp = now() -- so does now(); computed defaults
-- like words(...) are Stage 4+
service rest "/api/notes"
expose list, get, create, update, delete, subscribe
-- list/get/create/update/delete work today (Stage 2).
-- subscribe maps to GET /api/notes/live — a documented 501 stub
-- until Stage 3 lands LIVE subscriptions over WebSocket.
}
-- A second actor that modifies Note. The closest thing to "another class" in
-- writeonce is another type with a trigger: there is no class/method model —
-- behavior attaches to data. Creating a Revision rewrites its target note's
-- title, inside the same transaction as the insert. Triggers execute from
-- Stage 4; today the block parses and is discarded.
type Revision {
id: Id
note: ref Note -- foreign key into Note
new_title: Text
at: Timestamp = now()
on create
do update Note{ id == self.note }.title = self.new_title
}
-- Procedural entry point. Stage 2 parses and discards `main` blocks
-- (see crates/rt/src/parser.rs — skip_top_level_chunk); from Phase 6 on
-- this runs once at startup, before the HTTP server binds.
main {
insert Note { title: "hello", body: "# First note" };
-- A snapshot binding would never see later changes (let n = select ...).
-- LIVE is the language's "pointer that observes stores": the handle
-- receives a delta at every commit that touches a matching row. Stage 3.
-- (Spec: docs/runtime/database/02-wo-language.md § Schema-Layer DML)
let live = LIVE select Note{ title };
-- The other actor fires: this insert runs Revision's on-create trigger,
-- the trigger updates the note, and the commit pushes one delta.
insert Revision { note: 1, new_title: "Hello World" };
for delta in live {
print(delta.kind, delta.row.title); -- update "Hello World"
}
}

View file

@ -1,92 +0,0 @@
# mcp-think — a local-model MCP tool for Claude
A minimal, working **MCP server** that Claude (Claude Code or Claude Desktop) can call as a tool — and whose work runs entirely on **your machine**, on a local model served by [Ollama](https://ollama.com). One Python file, stdio transport, standard library only (plus the official `mcp` package).
Why offload from Claude to a local model:
| Reason | Example |
| --- | --- |
| **Privacy** | `summarize` a confidential document — the text never leaves the box |
| **Cost** | `brainstorm` 20 ideas or condense a 50k-line log at zero token cost |
| **Diversity** | `critique` from a different model family — an independent second opinion |
| **Bandwidth** | Claude stays on the main task while the local model grinds a side job |
## Tools
| Tool | What it does |
| --- | --- |
| `think(task, context?)` | Reason through a problem; conclusion first, reasoning after |
| `critique(work, focus?)` | Adversarial review — strongest objections, ranked |
| `brainstorm(topic, n?)` | `n` genuinely distinct ideas |
| `summarize(text, max_words?)` | Faithful local summary of large/sensitive material |
## Prerequisites
```bash
# 1. Ollama, serving a model
ollama serve # if not already running as a service
ollama pull qwen3:4b # the default model (or any other — see Configuration)
# 2. uv (provisions the `mcp` package on the fly) — or `pip install mcp`
```
For a quick low-disk trial, `ollama pull qwen3:0.6b` (~500 MB) works — set `THINK_MODEL=qwen3:0.6b`.
## Add to Claude Code
```bash
claude mcp add think -- uv run --with mcp python3 \
/abs/path/to/docs/examples/mcp-think/server.py
```
(replace with your absolute path; add `-e THINK_MODEL=...` before `--` to override the model). Or declare it in a project's `.mcp.json`:
```json
{
"mcpServers": {
"think": {
"command": "uv",
"args": ["run", "--with", "mcp", "python3",
"/abs/path/to/docs/examples/mcp-think/server.py"],
"env": { "THINK_MODEL": "qwen3:4b" }
}
}
}
```
Claude Desktop: the same `command`/`args`/`env` block goes under `mcpServers` in `claude_desktop_config.json`.
Then just ask: *"use the think tool to weigh epoll vs io_uring for the WAL path"*, *"brainstorm 10 names for this feature"*, *"summarize this log with the local model"* — or let Claude reach for the tools on its own (the tool descriptions say when each applies).
## Configuration
| Variable | Default | Meaning |
| --- | --- | --- |
| `OLLAMA_URL` | `http://localhost:11434` | Ollama daemon base URL |
| `THINK_MODEL` | `qwen3:4b` | any pulled Ollama model |
| `THINK_TIMEOUT` | `300` | per-call timeout, seconds |
Errors (daemon down, model not pulled) come back as readable tool results, so Claude can tell you exactly what to run.
## Smoke test without Claude
```bash
python3 docs/examples/mcp-think/test_client.py # uses uv + THINK_MODEL if set
```
Drives the server over stdio exactly like Claude does — `initialize` → `tools/list` → one `think` call — and prints the local model's answer.
## How this relates to writeonce
This example is the **consumer side** of MCP: Claude ← stdio → this server → local model. The **server side** is [plan 15](../../plan/15-mcp-streamable-http.md): the writeonce runtime itself becomes an MCP server over Streamable HTTP, generating tools/resources from `.wo` declarations — after 15d, a class method like [`Product.set_price`](../pricing/types/product.wo) is callable as an MCP tool with no Python in between. The two meet naturally: a writeonce app exposes its data as MCP tools, and a local model (through a server like this one) works over it.
## Ideas to build next
Same pattern (one file, stdio, local model), different capability:
- **mcp-redact** — strip PII/secrets from text locally *before* it is sent to any cloud model.
- **mcp-embed** — local embeddings (`ollama embed`) + a small vector index over a repo or notes; gives Claude semantic search without a cloud vector DB.
- **mcp-vision** — describe screenshots/diagrams via a local vision model (`llava`, `qwen2.5-vl`) for machines where images must stay local.
- **mcp-translate** — private translation of documents.
- **mcp-testgen** — bulk-generate test fixtures/edge cases on the local model, free of token cost.
- **mcp-judge** — a local LLM-as-judge for scoring outputs in eval loops, so the judge is independent of the model being judged.

View file

@ -1,147 +0,0 @@
#!/usr/bin/env python3
"""mcp-think — offload thinking to a LOCAL model, exposed to Claude over MCP.
A minimal MCP server (stdio transport) whose tools run entirely on your
machine: each tool sends a prompt to a local model served by Ollama and
returns the answer. Nothing in the tool inputs ever leaves the box.
Why offload to a local model from Claude?
* privacy — summarize/critique content that must not leave the machine
* cost — bulk work (summaries, drafts, idea generation) at zero token cost
* diversity — a second opinion from a different model family
* bandwidth — Claude stays on the main task while the local model grinds
Run (Claude Code):
claude mcp add think -- uv run --with mcp python3 /abs/path/to/server.py
Configuration (environment):
OLLAMA_URL base URL of the Ollama daemon (default http://localhost:11434)
THINK_MODEL model to use (default qwen3:4b)
THINK_TIMEOUT per-call timeout in seconds (default 300)
Dependencies: the `mcp` package only (`uv run --with mcp` provisions it).
The Ollama call uses the Python standard library — no SDK, no httpx.
"""
import json
import os
import re
import urllib.error
import urllib.request
# The SDK is renaming FastMCP → MCPServer; support both so the example works
# with the released `mcp` package today and with the renamed one later.
try:
from mcp.server.mcpserver import MCPServer as Server # unreleased SDK head
except ImportError:
from mcp.server.fastmcp import FastMCP as Server # mcp <= 1.x (PyPI)
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
THINK_MODEL = os.environ.get("THINK_MODEL", "qwen3:4b")
THINK_TIMEOUT = int(os.environ.get("THINK_TIMEOUT", "300"))
mcp = Server("think")
def ask_local_model(system: str, prompt: str) -> str:
"""One chat round against the local model. Errors come back as text so
the calling model can read them and tell the user what to fix."""
body = json.dumps({
"model": THINK_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"stream": False,
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/api/chat",
data=body,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=THINK_TIMEOUT) as resp:
data = json.load(resp)
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")[:300]
return (f"[mcp-think] the local model rejected the request "
f"(HTTP {e.code}): {detail}\n"
f"Is the model pulled? Try: ollama pull {THINK_MODEL}")
except (urllib.error.URLError, TimeoutError, OSError) as e:
return (f"[mcp-think] cannot reach the local model at {OLLAMA_URL} ({e}).\n"
f"Start it with `ollama serve`, pull the model with "
f"`ollama pull {THINK_MODEL}`, then retry.")
text = (data.get("message") or {}).get("content", "")
# Reasoning models may inline their chain of thought as <think>…</think>;
# strip it — the caller wants the conclusion, not the scratchpad.
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
return text or "[mcp-think] the local model returned an empty response"
@mcp.tool()
def think(task: str, context: str = "") -> str:
"""Reason through a problem on the local model and return its conclusion.
Call this to get an independent, private, zero-cost analysis of a
question — weighing a design trade-off, sanity-checking a plan, working
through logic — especially when a second opinion from a different model
family is valuable. `context` carries any background the task needs
(code, notes, constraints); it never leaves this machine.
"""
system = ("You are a careful reasoning assistant. Think the problem "
"through step by step, then answer with your conclusion first, "
"followed by the key reasoning in a few short paragraphs.")
prompt = f"{task}\n\n--- context ---\n{context}" if context else task
return ask_local_model(system, prompt)
@mcp.tool()
def critique(work: str, focus: str = "") -> str:
"""Adversarially review a piece of work (code, prose, a plan, a design)
on the local model and return the strongest objections.
Call this before committing to a decision or shipping a draft, when an
independent devil's advocate is useful, or when the material is private
and must be reviewed without leaving the machine. `focus` optionally
narrows the review (e.g. "error handling", "argument structure").
"""
system = ("You are a rigorous, adversarial reviewer. Find the strongest "
"objections: errors, gaps, risks, unstated assumptions. Rank "
"them most-severe first. Be specific — point at the exact "
"part you object to and say why. Do not pad with praise.")
prompt = f"Review the following{f', focusing on {focus}' if focus else ''}:\n\n{work}"
return ask_local_model(system, prompt)
@mcp.tool()
def brainstorm(topic: str, n: int = 5) -> str:
"""Generate `n` distinct ideas on a topic using the local model.
Call this to widen the option space cheaply before converging — naming,
approaches, test cases, failure modes, feature ideas. The local model's
different training makes its ideas usefully different from yours.
"""
system = ("You are a prolific idea generator. Produce genuinely distinct "
"ideas — different mechanisms, not rephrasings. One line of "
"pitch plus one line of how it would work, per idea.")
return ask_local_model(system, f"Generate {n} distinct ideas for: {topic}")
@mcp.tool()
def summarize(text: str, max_words: int = 200) -> str:
"""Summarize text on the local model — the content never leaves this
machine and costs no API tokens.
Call this to condense large material (logs, documents, transcripts,
diffs) before reasoning about it, or when the content is sensitive and
must stay local. The summary comes back; the original stays here.
"""
system = (f"Summarize faithfully in at most {max_words} words. Keep "
"concrete facts, numbers, names, and conclusions; drop filler. "
"Note anything surprising or anomalous explicitly.")
return ask_local_model(system, text)
if __name__ == "__main__":
mcp.run() # stdio — Claude launches this as a subprocess

View file

@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""Smoke-test mcp-think over stdio, exactly the way Claude drives it.
Launches server.py as a subprocess (via `uv run --with mcp`), performs the
MCP lifecycle (initialize → initialized), lists the tools, and calls
`think` once. Requires Ollama running with the configured model pulled.
Usage: python3 test_client.py # standard library only
THINK_MODEL=qwen3:0.6b python3 test_client.py
"""
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
def main() -> int:
proc = subprocess.Popen(
["uv", "run", "--with", "mcp", "python3", os.path.join(HERE, "server.py")],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
env=os.environ.copy(), text=True,
)
stdin, stdout = proc.stdin, proc.stdout
assert stdin is not None and stdout is not None
msg_id = 0
def send(method: str, params: dict | None = None, notify: bool = False) -> dict:
nonlocal msg_id
msg: dict = {"jsonrpc": "2.0", "method": method}
if params is not None:
msg["params"] = params
if not notify:
msg_id += 1
msg["id"] = msg_id
stdin.write(json.dumps(msg) + "\n")
stdin.flush()
if notify:
return {}
# stdio transport: one JSON-RPC message per line
line = stdout.readline()
if not line:
raise RuntimeError("server closed the pipe")
return json.loads(line)
try:
r = send("initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "mcp-think-smoke", "version": "0"},
})
server = r["result"]["serverInfo"]
print(f"initialize ok — server: {server['name']}")
send("notifications/initialized", {}, notify=True)
r = send("tools/list", {})
tools = [t["name"] for t in r["result"]["tools"]]
print(f"tools/list ok — {tools}")
assert {"think", "critique", "brainstorm", "summarize"} <= set(tools)
print("tools/call think(...) — waiting on the local model…")
r = send("tools/call", {
"name": "think",
"arguments": {"task": "In one sentence: why do write-ahead logs "
"fsync before acknowledging a commit?"},
})
content = r["result"]["content"][0]["text"]
print(f"\n--- local model says ---\n{content}\n")
if content.startswith("[mcp-think]"):
print("NOT OK — the server answered, but the local model is unavailable.")
return 1
print("OK — MCP lifecycle, tool discovery, and local-model call all work.")
return 0
finally:
stdin.close()
proc.terminate()
proc.wait(timeout=5)
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,63 +0,0 @@
# `pricing` — class model + live pricing demo
A `Price` class and a `Product` class with methods — products have prices — and a `/pricing` screen that shows **live price data for selected products**. Data stays in RAM; one product is readable by millions of customers at once; a price update pushes live to millions of subscribers; all concurrency is Linux kernel I/O (epoll today, io_uring per the scale-out plan).
> **Status: 13a + 13b shipped.** `class` declarations parse, the demo serves real REST CRUD, **and methods execute over RPC** — `wo run docs/examples/pricing` (or `just pricing-demo`) serves `POST /api/products/:id/set_price` and `:id/current_price` as row-scoped transactions: the whole body commits as one WAL frame, an `assert … otherwise abort` rolls everything back (409). `subscribe` is a 501 stub until 13c; the UI is design-only until 13d/plan 14. The master plan is [`docs/plan/13-class-model-live-pricing.md`](../../plan/13-class-model-live-pricing.md).
## The class model in one paragraph
`class` = **state + methods, no inheritance**. Fields, defaults, `ref`/`multi`, `service`, `policy`, `on <event>` — all exactly as in `type` — plus `fn` methods with an implicit `self` receiver that run as row-scoped transactions (`in txn [snapshot]`, the same machinery as the ecommerce sample's free-standing `fn checkout`). No `extends`, no override, no virtual dispatch: "is-a" is a tagged union, "has-a" is a `ref`/`multi` edge. Go-style encapsulation, not Java-style hierarchies.
## Layout
The UI follows **MVC** ([`docs/plan/exploration/ui/08-mvc-structure.md`](../../plan/exploration/ui/08-mvc-structure.md)), with the same screen anatomy as the v1 Angular app (`reference/writeonce-app/src/app/article/`) collapsed into the single binary: the **model** is the class itself, the **view** is plain `.htmlx` with external `.scss`, and the **controller** is a `.wo` file that binds the model into the view and is the only place UI may call class methods.
```
pricing/
├── wo.toml # app manifest
├── main.wo # entry point: insert product, LIVE subscribe, set_price
├── types/ # MODEL — classes: schema + methods
│ ├── price.wo # class Price — amount/currency/at + fn discounted(pct)
│ └── product.wo # class Product — prices: multi Price + fn current_price /
│ # fn set_price + service rest /api/products
└── ui/
└── pricing/ # one screen = one MVC triplet
├── pricing.wo # CONTROLLER — route, model: bindings, actions → class methods
├── pricing.htmlx # VIEW — plain htmlx (Mustache + wo:live/wo:bind), logic-free
└── pricing.scss # VIEW styles — external SCSS, compiled at `wo build`
```
## What runs when
| File / feature | Goes live in | Plan |
| --- | --- | --- |
| `class` parses; `/api/products` CRUD serves | **13a ✅ shipped** | lexer/parser/AST + spec amendments |
| `set_price` / `current_price` over RPC (`POST /api/products/:id/set_price`) | **13b ✅ shipped** | method execution, row-scoped txn, one WAL frame per call |
| `@table(name: "prices", index: [product, at])` + indexed DML — `history()` via `select Price{ product == self.id }`, `GET /api/prices?product=1` | **✅ shipped (13 follow-up)** | engine secondary indexes, `find_by`, REST filters |
| `subscribe` / `LIVE select` — delta on every commit, WebSocket at `/api/products/live` | **13c** | subscription registry (scoped Stage 3) |
| `/pricing` screen patches price cells in open browsers | **13d** | SSR + `wo:live`/`wo:bind` client runtime |
| Millions of readers + millions of live recipients | **13e** | scale targets + load harness |
## The scale story (13e)
How "one product, millions of customers" works — all of it existing design, instantiated for this demo:
- **RAM-resident.** The engine is the in-memory design of [`03-inmemory-engine.md`](../../runtime/database/03-inmemory-engine.md); the WAL/disk phases (10–12) sit *behind* the read path for durability, never in front of it.
- **Reads scale across cores.** Thread-per-core, shared-nothing shards behind `SO_REUSEPORT` ([`09-concurrency-scaleout.md`](../../plan/09-concurrency-scaleout.md)). A hot product row is owned by one shard but read-replicated to every thread, refreshed by the same per-thread broadcast that feeds subscribers — so `GET /api/products/1` spreads over all cores with zero contention, while writes keep a single owner.
- **Live updates fan out in two stages.** One `set_price` commit → one delta message **per thread** (not per subscriber, per plan 09d) → each thread predicate-matches its local subscription table and batches socket writes on its own ring. Kernel primitives only: edge-triggered epoll today ([`done/02-event-loop-epoll.md`](../../plan/done/02-event-loop-epoll.md)), per-thread io_uring next ([`exploration/linux/07-io_uring.md`](../../plan/exploration/linux/07-io_uring.md)).
Verification targets (1 M aggregate reads/s of one product on 16 cores, 1 M live subscribers with p99 delta delivery < 250 ms, commit→first-delta p99 < 10 ms) are defined in [plan 13 § 13e](../../plan/13-class-model-live-pricing.md).
## Try it today
```bash
just pricing-demo # scripted CRUD round-trip, self-contained
# or:
cargo run --bin wo -- run docs/examples/pricing
# [wo] compiled catalog — 2 types
curl -X POST localhost:8080/api/products \
-H "Content-Type: application/json" -d '{"sku":"WO-001","name":"writeonce mug"}'
curl localhost:8080/api/products
```
Methods and live push are the next sub-phases (13b/13c). For the `type`-based minimal program, see [`../hello/`](../hello/); for the full workspace shape, [`../../examples/ecommerce/`](../ecommerce/).

View file

@ -1,25 +0,0 @@
-- pricing — Phase 13 demo entry point.
-- (Design artifact: Stage 2 parses and discards `main` blocks; this executes
-- from Phase 6. The annotated walkthrough mirrors docs/examples/hello/main.wo.)
main {
insert Product { sku: "WO-001", name: "writeonce mug" };
-- LIVE binding (13c): the handle receives a delta at every commit that
-- touches a matching row — the "pointer that observes stores". A plain
-- `let p = select ...` would be a snapshot and never see later changes.
-- Brace rule: bare identifiers are projections, so this subscribes to
-- the { name, prices } shape of all products.
-- (Spec: docs/runtime/database/02-wo-language.md § Schema-Layer DML)
let live = LIVE select Product{ name, prices };
-- Method call (13b): row-scoped transaction — inserts a Price, commits,
-- and the commit fans the delta out to `live` and to every browser on
-- the /pricing screen (13d). At scale: one commit → one message per
-- core → batched socket writes to millions of subscribers (13e).
Product{ sku == "WO-001" }.set_price(4999);
for delta in live {
print(delta.kind, delta.row.name); -- update "writeonce mug"
}
}

View file

@ -1,31 +0,0 @@
-- Price — a `class`: state + methods, no inheritance.
-- (Phase 13 design artifact — see docs/plan/13-class-model-live-pricing.md.
-- The Stage 2 parser skips `class` blocks; this parses as real syntax from 13a.)
--
-- Prices are append-only history rows: setting a new price inserts a Price,
-- it never mutates an old one. A product's "current price" is the latest row.
--
-- @table configures storage — it never toggles it (every class IS a table):
-- name: the storage/table name for the SQL layer and plans 10–12
-- index: composite secondary index — accelerates `self.prices`,
-- `select Price{ product == ... }`, and GET /api/prices?product=N
@table(name: "prices", index: [product, at])
class Price {
id: Id
product: ref Product -- owning product (foreign key)
amount: Int -- minor units (cents)
currency: Text = "EUR"
at: Timestamp = now()
-- Method: implicit `self` receiver, like Go methods — no class hierarchy,
-- just behavior attached to a row. Pure computation, so no `in txn`.
fn discounted(pct: Int) -> Int {
return self.amount * (100 - pct) / 100;
}
-- Read-only history endpoint; `GET /api/prices?product=1` filters via
-- the (product, at) index above.
service rest "/api/prices"
expose list
}

View file

@ -1,39 +0,0 @@
-- Product — a `class` with state, methods, and a REST surface.
-- (Phase 13 design artifact — see docs/plan/13-class-model-live-pricing.md.)
--
-- "Products have prices": composition via `multi`, not inheritance. The
-- price history is its own class (types/price.wo); the product owns the
-- collection edge.
class Product {
id: Id
sku: Text @unique
name: Text
prices: multi Price -- append-only price history
-- Row-scoped transactional method (13b): runs inside a snapshot of the
-- receiving row, exposed as POST /api/products/:id/current_price.
fn current_price() -> Int in txn {
return latest(self.prices).amount;
}
-- The write path of the live-pricing demo. The assert and the insert
-- commit or roll back together (one WAL frame); the commit pushes one
-- delta to every subscriber of this product (13c) and patches every open
-- pricing screen (13d).
fn set_price(amount: Int) in txn {
assert amount > 0 otherwise abort "price must be positive"
insert Price { product: self.id, amount: amount };
}
-- Schema-layer select (§ Brace Disambiguation): `product == self.id` is a
-- predicate (rides Price's (product, at) index), `amount`/`at` project.
fn history() -> [Price] in txn {
return select Price{ product == self.id, amount, at };
}
-- CRUD works from 13a (classes are storage-identical to types);
-- subscribe goes live in 13c.
service rest "/api/products"
expose list, get, create, update, delete, subscribe
}

View file

@ -1,37 +0,0 @@
{{!--
pricing.htmlx — the VIEW of the /pricing screen (MVC). Plain htmlx:
Mustache + <wo:live>/wo:bind per docs/plan/exploration/ui/01-htmlx-format-spec.md.
Logic-free — `products` and `watchlist` come from the controller's model:
block (pricing.wo); actions are raised by name, the controller dispatches.
Live behavior (13c/13d): when set_price commits, only the wo:bind cells of
the affected row patch in place — no reload, no row re-render.
--}}
<section class="pricing">
<h1>Live pricing</h1>
<wo:live source="products" key="id" sort="name asc">
<table class="price-table">
<thead>
<tr><th></th><th>Product</th><th>SKU</th><th>Price</th><th>Updated</th></tr>
</thead>
<tbody>
{{#each products as p}}
<tr data-key="{{p.id}}" class="{{#if (in watchlist p.id)}}watched{{/if}}">
<td class="watch-toggle">
{{#if (in watchlist p.id)}}
<button wo:action="unwatch" wo:args="{{p.id}}">★</button>
{{else}}
<button wo:action="watch" wo:args="{{p.id}}">☆</button>
{{/if}}
</td>
<td class="name">{{p.name}}</td>
<td class="sku">{{p.sku}}</td>
<td class="price" wo:bind="prices">{{> money amount=p.current_price}}</td>
<td class="updated" wo:bind="prices">{{relative p.prices[-1].at}}</td>
</tr>
{{/each}}
</tbody>
</table>
</wo:live>
</section>

View file

@ -1,39 +0,0 @@
// pricing.scss — VIEW styles of the /pricing screen (MVC), external to the
// markup. Compiled to flat CSS by `wo build` (strict SCSS subset: variables,
// nesting, @use of partials — see docs/plan/exploration/ui/08-mvc-structure.md
// decision 3) and served as a static asset via sendfile.
$accent: #0a7d4f;
$muted: #6b7280;
$border: #e5e7eb;
.pricing {
max-width: 56rem;
margin: 0 auto;
.price-table {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
color: $muted;
border-bottom: 2px solid $border;
}
td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $border;
}
.price {
color: $accent;
font-variant-numeric: tabular-nums; // cells patch live; keep digits steady
}
.sku { font-family: monospace; }
.updated { color: $muted; }
tr.watched { background: rgba($accent, 0.06); }
}
}

View file

@ -1,29 +0,0 @@
-- pricing.wo — the CONTROLLER of the /pricing screen (MVC).
-- (Phase 13 design artifact; executes from 13d. MVC structure:
-- docs/plan/exploration/ui/08-mvc-structure.md. Anatomy mirrors the v1
-- Angular component reference/writeonce-app/src/app/article/ —
-- view:/styles: ≈ templateUrl/styleUrl, model: ≈ component fields,
-- actions: ≈ component methods.)
--
-- The controller binds the MODEL (class Product, types/product.wo) into the
-- VIEW (pricing.htmlx) and is the only place the UI may call class methods.
-- No service layer: the database is in-process, a model binding IS a query.
##ui
#pricing
route: /pricing
view: pricing.htmlx -- View: plain htmlx, logic-free
styles: pricing.scss -- View styles: external SCSS, compiled at `wo build`
-- Model → View binding. These names are the root scope of pricing.htmlx.
-- LIVE bindings re-patch the view on every commit that touches a match.
model:
products: LIVE select Product{ name, sku, prices }
watchlist: $session.watchlist
-- Controller actions — handlers call class methods (plan 13b).
-- The view raises them via wo:action; it never calls methods directly.
actions:
set-price(id, amount): Product{ id == id }.set_price(amount) role: Ops | Admin
watch(id): session.watchlist += id
unwatch(id): session.watchlist -= id

View file

@ -1,15 +0,0 @@
name = "pricing"
version = "0.1.0"
description = "Phase 13 demo: class model (state + methods, no inheritance) + live pricing fan-out"
[runtime]
wo = ">= 0.1"
[app]
listen = "127.0.0.1:8080"
# RAM-resident engine; disk (phases 10-12) is durability behind the read path,
# never in front of it. See docs/plan/13-class-model-live-pricing.md § 13e.
[database]
data_dir = "./data"
isolation = "snapshot"

View file

@ -1,9 +1,5 @@
# writeonce — task runner. `just --list` shows all recipes.
# serve the hello example (docs/examples/hello/main.wo) on :8080
hello:
cargo run --bin wo -- run docs/examples/hello
# C runtime reference (prototypes/wo-rt-c): build, serve, CRUD round-trip, shut down
# Phase A: thread-per-core — each connection hashes to one shard (SO_REUSEPORT),
# so a list may land on a different shard than the create. The counters on /
@ -147,80 +143,3 @@ rt-c-bench port="8085" threads="8" conns="64":
$B $base {{port}} {{conns}} 5 /
$B $base {{port}} {{conns}} 3 /api/notes '{"title":"bench"}'
$B $base {{port}} 10000 0 /healthz
# serve the pricing demo (class model — docs/examples/pricing) on :8080
pricing:
cargo run --bin wo -- run docs/examples/pricing
# class model in action: CRUD + row-scoped method RPC (13b) on Product.
# WO_THREADS=1 + WO_DATA=off keep ids deterministic and the demo stateless.
pricing-demo port="8092":
#!/usr/bin/env bash
set -euo pipefail
cargo build --bin wo
WO_THREADS=1 WO_DATA=off WO_LISTEN=127.0.0.1:{{port}} ./target/debug/wo run docs/examples/pricing &
server=$!
trap 'kill $server 2>/dev/null' EXIT
base=http://127.0.0.1:{{port}}
for _ in $(seq 1 40); do curl -s "$base/healthz" >/dev/null && break; sleep 0.25; done
echo
echo "--- create:"; curl -s -X POST "$base/api/products" -H 'Content-Type: application/json' -d '{"sku":"WO-001","name":"writeonce mug"}'; echo
echo "--- list:"; curl -s "$base/api/products"; echo
echo "--- patch 1:"; curl -s -X PATCH "$base/api/products/1" -d '{"name":"writeonce mug v2"}'; echo
echo "--- set_price 4999 (method RPC, expect 200):"; curl -s -X POST "$base/api/products/1/set_price" -d '{"amount":4999}' -o /dev/null -w '%{http_code}\n'
echo "--- set_price 5999 (expect 200):"; curl -s -X POST "$base/api/products/1/set_price" -d '{"amount":5999}' -o /dev/null -w '%{http_code}\n'
echo "--- current_price (expect 5999):"; curl -s -X POST "$base/api/products/1/current_price"; echo
echo "--- set_price 0 (assert aborts, expect 409):"; curl -s -X POST "$base/api/products/1/set_price" -d '{"amount":0}'; echo
echo "--- current_price unchanged (expect 5999):"; curl -s -X POST "$base/api/products/1/current_price"; echo
echo "--- price history via select (projected amount+at):"; curl -s -X POST "$base/api/products/1/history"; echo
echo "--- indexed REST filter ?product=1:"; curl -s "$base/api/prices?product=1"; echo
echo "--- live (13c pending, expect 501):"; curl -s -o /dev/null -w '%{http_code}\n' "$base/api/products/live"
echo "--- delete 1 (expect 204):"; curl -s -X DELETE "$base/api/products/1" -o /dev/null -w '%{http_code}\n'
# Postgres backup mirror (plan 16): throwaway postgres:16 container, pricing
# demo with WO_PG, verify rows via psql, tear everything down.
# Needs docker + psql. RAM stays authoritative — psql is the backup view.
pricing-pg-demo port="8093" pgport="54331":
#!/usr/bin/env bash
set -euo pipefail
cargo build --bin wo
docker rm -f wo-pg-demo >/dev/null 2>&1 || true
docker run -d --rm --name wo-pg-demo -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=wo -p {{pgport}}:5432 postgres:16 >/dev/null
trap 'kill $server 2>/dev/null || true; docker rm -f wo-pg-demo >/dev/null 2>&1 || true' EXIT
for _ in $(seq 1 60); do psql -h 127.0.0.1 -p {{pgport}} -U postgres wo -c 'select 1' >/dev/null 2>&1 && break; sleep 0.5; done
WO_THREADS=1 WO_DATA=off WO_PG=postgres://postgres@127.0.0.1:{{pgport}}/wo WO_LISTEN=127.0.0.1:{{port}} \
./target/debug/wo run docs/examples/pricing &
server=$!
base=http://127.0.0.1:{{port}}
for _ in $(seq 1 40); do curl -s "$base/healthz" >/dev/null && break; sleep 0.25; done
echo
echo "--- create + set_price 4999, 5999 (RAM ack; mirror follows):"
curl -s -X POST "$base/api/products" -d '{"sku":"WO-001","name":"writeonce mug"}'; echo
curl -s -o /dev/null -X POST "$base/api/products/1/set_price" -d '{"amount":4999}'
curl -s -o /dev/null -X POST "$base/api/products/1/set_price" -d '{"amount":5999}'
echo "--- set_price 0 (aborts; must NOT reach Postgres):"
curl -s -X POST "$base/api/products/1/set_price" -d '{"amount":0}'; echo
sleep 1
echo "--- psql: the backup view (table name from @table(name: \"prices\")):"
psql -h 127.0.0.1 -p {{pgport}} -U postgres wo -c "SELECT id, row->>'amount' AS amount, row->>'at' AS at FROM prices ORDER BY id"
echo "--- current_price from RAM (reads never touch Postgres):"
curl -s -X POST "$base/api/products/1/current_price"; echo
# main.wo in action: serve hello, run the full CRUD round-trip, shut down
hello-demo port="8090":
#!/usr/bin/env bash
set -euo pipefail
cargo build --bin wo
WO_LISTEN=127.0.0.1:{{port}} ./target/debug/wo run docs/examples/hello &
server=$!
trap 'kill $server 2>/dev/null' EXIT
base=http://127.0.0.1:{{port}}
for _ in $(seq 1 40); do curl -s "$base/healthz" >/dev/null && break; sleep 0.25; done
echo
echo "--- create:"; curl -s -X POST "$base/api/notes" -H 'Content-Type: application/json' -d '{"title":"hello","body":"# First note"}'; echo
echo "--- list:"; curl -s "$base/api/notes"; echo
echo "--- get 1:"; curl -s "$base/api/notes/1"; echo
echo "--- patch 1:"; curl -s -X PATCH "$base/api/notes/1" -d '{"pinned":true}'; echo
echo "--- live (Stage 3 stub, expect 501):"; curl -s -o /dev/null -w '%{http_code}\n' "$base/api/notes/live"
echo "--- delete 1 (expect 204):"; curl -s -X DELETE "$base/api/notes/1" -o /dev/null -w '%{http_code}\n'
echo "--- list after delete:"; curl -s "$base/api/notes"; echo