diff --git a/.planboard.json b/.planboard.json new file mode 100644 index 0000000..04e0b33 --- /dev/null +++ b/.planboard.json @@ -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" + } + } + } + } +} diff --git a/.planboard/work/docs--plan--compiler--2026-08-01-haxe-parity-language/progress.md b/.planboard/work/docs--plan--compiler--2026-08-01-haxe-parity-language/progress.md new file mode 100644 index 0000000..37ece26 --- /dev/null +++ b/.planboard/work/docs--plan--compiler--2026-08-01-haxe-parity-language/progress.md @@ -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 + diff --git a/crates/rt/src/parser.rs b/crates/rt/src/parser.rs index f047df1..f54d741 100644 --- a/crates/rt/src/parser.rs +++ b/crates/rt/src/parser.rs @@ -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()); diff --git a/docs/examples/agent-loop/README.md b/docs/examples/agent-loop/README.md deleted file mode 100644 index f012a34..0000000 --- a/docs/examples/agent-loop/README.md +++ /dev/null @@ -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) diff --git a/docs/examples/agent-loop/agent.py b/docs/examples/agent-loop/agent.py deleted file mode 100644 index a7eb0c2..0000000 --- a/docs/examples/agent-loop/agent.py +++ /dev/null @@ -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 …; - # the user wants the conclusion, not the scratchpad. - return re.sub(r".*?", "", 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() diff --git a/docs/examples/agent-loop/test_loop.py b/docs/examples/agent-loop/test_loop.py deleted file mode 100644 index bb8388c..0000000 --- a/docs/examples/agent-loop/test_loop.py +++ /dev/null @@ -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()) diff --git a/docs/examples/blog/README.md b/docs/examples/blog/README.md deleted file mode 100644 index 9a6814e..0000000 --- a/docs/examples/blog/README.md +++ /dev/null @@ -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: ` + `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 `` 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=""] `, 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 diff --git a/docs/examples/blog/api.rest b/docs/examples/blog/api.rest deleted file mode 100644 index 2ffcc48..0000000 --- a/docs/examples/blog/api.rest +++ /dev/null @@ -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 diff --git a/docs/examples/blog/app.wo b/docs/examples/blog/app.wo deleted file mode 100644 index cde95e2..0000000 --- a/docs/examples/blog/app.wo +++ /dev/null @@ -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 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 - }; - } -} diff --git a/docs/examples/blog/tests/article_test.wo b/docs/examples/blog/tests/article_test.wo deleted file mode 100644 index d3f2b35..0000000 --- a/docs/examples/blog/tests/article_test.wo +++ /dev/null @@ -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"; -} diff --git a/docs/examples/blog/types/article.wo b/docs/examples/blog/types/article.wo deleted file mode 100644 index 2ee941a..0000000 --- a/docs/examples/blog/types/article.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/blog/types/author.wo b/docs/examples/blog/types/author.wo deleted file mode 100644 index 28f4a10..0000000 --- a/docs/examples/blog/types/author.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/blog/types/comment.wo b/docs/examples/blog/types/comment.wo deleted file mode 100644 index a51466a..0000000 --- a/docs/examples/blog/types/comment.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/blog/types/tag.wo b/docs/examples/blog/types/tag.wo deleted file mode 100644 index d0a8922..0000000 --- a/docs/examples/blog/types/tag.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/blog/ui/article_detail.wo b/docs/examples/blog/ui/article_detail.wo deleted file mode 100644 index 62731f4..0000000 --- a/docs/examples/blog/ui/article_detail.wo +++ /dev/null @@ -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 diff --git a/docs/examples/blog/ui/article_list.wo b/docs/examples/blog/ui/article_list.wo deleted file mode 100644 index a5c314f..0000000 --- a/docs/examples/blog/ui/article_list.wo +++ /dev/null @@ -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 diff --git a/docs/examples/blog/ui/components/article-card.css b/docs/examples/blog/ui/components/article-card.css deleted file mode 100644 index a0ccd8b..0000000 --- a/docs/examples/blog/ui/components/article-card.css +++ /dev/null @@ -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; } diff --git a/docs/examples/blog/ui/components/article-card.htmlx b/docs/examples/blog/ui/components/article-card.htmlx deleted file mode 100644 index 8f0f6af..0000000 --- a/docs/examples/blog/ui/components/article-card.htmlx +++ /dev/null @@ -1,22 +0,0 @@ -
-

- {{article.title}} -

- -

- {{article.author.display}} - -

- - {{#if article.meta.excerpt}} -

{{article.meta.excerpt}}

- {{/if}} - - {{#if article.tags}} -
    - {{#each article.tags as tag}} -
  • {{tag.name}}
  • - {{/each}} -
- {{/if}} -
diff --git a/docs/examples/blog/ui/components/article-card.wo b/docs/examples/blog/ui/components/article-card.wo deleted file mode 100644 index d657e2f..0000000 --- a/docs/examples/blog/ui/components/article-card.wo +++ /dev/null @@ -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 diff --git a/docs/examples/blog/ui/components/comments.css b/docs/examples/blog/ui/components/comments.css deleted file mode 100644 index 750cd74..0000000 --- a/docs/examples/blog/ui/components/comments.css +++ /dev/null @@ -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"] , 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; } diff --git a/docs/examples/blog/ui/components/comments.htmlx b/docs/examples/blog/ui/components/comments.htmlx deleted file mode 100644 index 81a34b0..0000000 --- a/docs/examples/blog/ui/components/comments.htmlx +++ /dev/null @@ -1,25 +0,0 @@ -
-
-

Comments

-
- -
    - {{#each comments as comment}} -
  • -
    - {{comment.author.display}} - -
    -

    {{comment.body}}

    -
  • - {{/each}} -
- - {{#when actions.create}} -
- - - -
- {{/when}} -
diff --git a/docs/examples/blog/ui/components/comments.wo b/docs/examples/blog/ui/components/comments.wo deleted file mode 100644 index a4acae3..0000000 --- a/docs/examples/blog/ui/components/comments.wo +++ /dev/null @@ -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 diff --git a/docs/examples/blog/wo.toml b/docs/examples/blog/wo.toml deleted file mode 100644 index 4b57661..0000000 --- a/docs/examples/blog/wo.toml +++ /dev/null @@ -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 diff --git a/docs/examples/ecommerce/README.md b/docs/examples/ecommerce/README.md deleted file mode 100644 index e9b32ab..0000000 --- a/docs/examples/ecommerce/README.md +++ /dev/null @@ -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//ui//`) 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) diff --git a/docs/examples/ecommerce/api.rest b/docs/examples/ecommerce/api.rest deleted file mode 100644 index 019fa80..0000000 --- a/docs/examples/ecommerce/api.rest +++ /dev/null @@ -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 diff --git a/docs/examples/ecommerce/apps/admin/app.wo b/docs/examples/ecommerce/apps/admin/app.wo deleted file mode 100644 index 10a49fa..0000000 --- a/docs/examples/ecommerce/apps/admin/app.wo +++ /dev/null @@ -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 diff --git a/docs/examples/ecommerce/apps/admin/ui/orders/orders.wo b/docs/examples/ecommerce/apps/admin/ui/orders/orders.wo deleted file mode 100644 index b64217f..0000000 --- a/docs/examples/ecommerce/apps/admin/ui/orders/orders.wo +++ /dev/null @@ -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 diff --git a/docs/examples/ecommerce/apps/admin/wo.toml b/docs/examples/ecommerce/apps/admin/wo.toml deleted file mode 100644 index 7ba7029..0000000 --- a/docs/examples/ecommerce/apps/admin/wo.toml +++ /dev/null @@ -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" diff --git a/docs/examples/ecommerce/apps/storefront/app.wo b/docs/examples/ecommerce/apps/storefront/app.wo deleted file mode 100644 index 0cd3cfa..0000000 --- a/docs/examples/ecommerce/apps/storefront/app.wo +++ /dev/null @@ -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/ diff --git a/docs/examples/ecommerce/apps/storefront/ui/home/home.wo b/docs/examples/ecommerce/apps/storefront/ui/home/home.wo deleted file mode 100644 index c574be5..0000000 --- a/docs/examples/ecommerce/apps/storefront/ui/home/home.wo +++ /dev/null @@ -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: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 diff --git a/docs/examples/ecommerce/apps/storefront/ui/orders/orders.wo b/docs/examples/ecommerce/apps/storefront/ui/orders/orders.wo deleted file mode 100644 index 2372468..0000000 --- a/docs/examples/ecommerce/apps/storefront/ui/orders/orders.wo +++ /dev/null @@ -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: "/" } diff --git a/docs/examples/ecommerce/apps/storefront/wo.toml b/docs/examples/ecommerce/apps/storefront/wo.toml deleted file mode 100644 index 21f1634..0000000 --- a/docs/examples/ecommerce/apps/storefront/wo.toml +++ /dev/null @@ -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" diff --git a/docs/examples/ecommerce/shared/components/layout.htmlx b/docs/examples/ecommerce/shared/components/layout.htmlx deleted file mode 100644 index f42bb01..0000000 --- a/docs/examples/ecommerce/shared/components/layout.htmlx +++ /dev/null @@ -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. ---}} - - - - - {{page.title}} — {{app.name}} - - {{> slot.head}} - - - - -
- {{> slot.content}} -
- -
- Built with writeonce · {{app.version}} -
- - {{!-- Client runtime + subscription manifest are injected automatically. --}} - - - diff --git a/docs/examples/ecommerce/shared/components/money.htmlx b/docs/examples/ecommerce/shared/components/money.htmlx deleted file mode 100644 index 7102c9c..0000000 --- a/docs/examples/ecommerce/shared/components/money.htmlx +++ /dev/null @@ -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. ---}} -${{divide amount by 100}}.{{mod amount by 100 padleft 2 with "0"}} diff --git a/docs/examples/ecommerce/shared/components/order-row.htmlx b/docs/examples/ecommerce/shared/components/order-row.htmlx deleted file mode 100644 index e507632..0000000 --- a/docs/examples/ecommerce/shared/components/order-row.htmlx +++ /dev/null @@ -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"}} ---}} - - {{id}} - {{status}} - {{#if (eq for "ops")}} - {{customer.name}} - {{customer.email}} - {{/if}} - {{> money amount=total}} - {{relative placed_at}} - {{#if (eq for "ops")}} - {{relative paid_at}} - {{relative shipped_at}} - {{/if}} - diff --git a/docs/examples/ecommerce/shared/logic/checkout.wo b/docs/examples/ecommerce/shared/logic/checkout.wo deleted file mode 100644 index ca09260..0000000 --- a/docs/examples/ecommerce/shared/logic/checkout.wo +++ /dev/null @@ -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; -} diff --git a/docs/examples/ecommerce/shared/logic/seed.wo b/docs/examples/ecommerce/shared/logic/seed.wo deleted file mode 100644 index 4456da2..0000000 --- a/docs/examples/ecommerce/shared/logic/seed.wo +++ /dev/null @@ -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 } - }; - } -} diff --git a/docs/examples/ecommerce/shared/types/customer.wo b/docs/examples/ecommerce/shared/types/customer.wo deleted file mode 100644 index 482bd8c..0000000 --- a/docs/examples/ecommerce/shared/types/customer.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/ecommerce/shared/types/order.wo b/docs/examples/ecommerce/shared/types/order.wo deleted file mode 100644 index 2bc8c9d..0000000 --- a/docs/examples/ecommerce/shared/types/order.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/ecommerce/shared/types/product.wo b/docs/examples/ecommerce/shared/types/product.wo deleted file mode 100644 index d7f272b..0000000 --- a/docs/examples/ecommerce/shared/types/product.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/ecommerce/shared/types/purchase.wo b/docs/examples/ecommerce/shared/types/purchase.wo deleted file mode 100644 index 8fe9a1c..0000000 --- a/docs/examples/ecommerce/shared/types/purchase.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/ecommerce/tests/checkout_test.wo b/docs/examples/ecommerce/tests/checkout_test.wo deleted file mode 100644 index 9b996cd..0000000 --- a/docs/examples/ecommerce/tests/checkout_test.wo +++ /dev/null @@ -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; -} diff --git a/docs/examples/ecommerce/wo.toml b/docs/examples/ecommerce/wo.toml deleted file mode 100644 index b8b0866..0000000 --- a/docs/examples/ecommerce/wo.toml +++ /dev/null @@ -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 diff --git a/docs/examples/hello/main.wo b/docs/examples/hello/main.wo deleted file mode 100644 index 7682c31..0000000 --- a/docs/examples/hello/main.wo +++ /dev/null @@ -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" - } -} diff --git a/docs/examples/mcp-think/README.md b/docs/examples/mcp-think/README.md deleted file mode 100644 index ab1275b..0000000 --- a/docs/examples/mcp-think/README.md +++ /dev/null @@ -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. diff --git a/docs/examples/mcp-think/server.py b/docs/examples/mcp-think/server.py deleted file mode 100644 index 0d7ece7..0000000 --- a/docs/examples/mcp-think/server.py +++ /dev/null @@ -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 …; - # strip it — the caller wants the conclusion, not the scratchpad. - text = re.sub(r".*?", "", 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 diff --git a/docs/examples/mcp-think/test_client.py b/docs/examples/mcp-think/test_client.py deleted file mode 100644 index 870e3b5..0000000 --- a/docs/examples/mcp-think/test_client.py +++ /dev/null @@ -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()) diff --git a/docs/examples/pricing/README.md b/docs/examples/pricing/README.md deleted file mode 100644 index 6e35c44..0000000 --- a/docs/examples/pricing/README.md +++ /dev/null @@ -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 ` — 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/). diff --git a/docs/examples/pricing/main.wo b/docs/examples/pricing/main.wo deleted file mode 100644 index 9af45d1..0000000 --- a/docs/examples/pricing/main.wo +++ /dev/null @@ -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" - } -} diff --git a/docs/examples/pricing/types/price.wo b/docs/examples/pricing/types/price.wo deleted file mode 100644 index e80ce57..0000000 --- a/docs/examples/pricing/types/price.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/pricing/types/product.wo b/docs/examples/pricing/types/product.wo deleted file mode 100644 index c6f0678..0000000 --- a/docs/examples/pricing/types/product.wo +++ /dev/null @@ -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 -} diff --git a/docs/examples/pricing/ui/pricing/pricing.htmlx b/docs/examples/pricing/ui/pricing/pricing.htmlx deleted file mode 100644 index e64629e..0000000 --- a/docs/examples/pricing/ui/pricing/pricing.htmlx +++ /dev/null @@ -1,37 +0,0 @@ -{{!-- - pricing.htmlx — the VIEW of the /pricing screen (MVC). Plain htmlx: - Mustache + /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. ---}} -
-

Live pricing

- - - - - - - - {{#each products as p}} - - - - - - - - {{/each}} - -
ProductSKUPriceUpdated
- {{#if (in watchlist p.id)}} - - {{else}} - - {{/if}} - {{p.name}}{{p.sku}}{{> money amount=p.current_price}}{{relative p.prices[-1].at}}
-
-
diff --git a/docs/examples/pricing/ui/pricing/pricing.scss b/docs/examples/pricing/ui/pricing/pricing.scss deleted file mode 100644 index 89bbc0a..0000000 --- a/docs/examples/pricing/ui/pricing/pricing.scss +++ /dev/null @@ -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); } - } -} diff --git a/docs/examples/pricing/ui/pricing/pricing.wo b/docs/examples/pricing/ui/pricing/pricing.wo deleted file mode 100644 index d9440a5..0000000 --- a/docs/examples/pricing/ui/pricing/pricing.wo +++ /dev/null @@ -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 diff --git a/docs/examples/pricing/wo.toml b/docs/examples/pricing/wo.toml deleted file mode 100644 index adb4c34..0000000 --- a/docs/examples/pricing/wo.toml +++ /dev/null @@ -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" diff --git a/justfile b/justfile index a5b31f1..5910353 100644 --- a/justfile +++ b/justfile @@ -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