chore: keep only the log-watcher example (pre-merge cleanup)
- docs/examples/{agent-loop,blog,ecommerce,hello,mcp-think,pricing}
removed; every deleted tree is preserved on branch
cleanup/non-logwatcher-examples (snapshot of this branch pre-delete)
- justfile: hello/pricing/pricing-demo/pricing-pg-demo/hello-demo
recipes removed with the examples they served (Rust-runtime demos);
rt-c-* prototype recipes and every gate recipe stay
- crates/rt parser test article_debug: the blog fixture it read from
disk now lives inline, same shape, so cargo test needs no example
- gates after cleanup: cargo test -p rt 69/0, oop-e2e 71/0, woc-test
green, log-watcher 7/0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4d2a588ff
commit
5e27b2f685
56 changed files with 87 additions and 3102 deletions
29
.planboard.json
Normal file
29
.planboard.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"plans": {
|
||||||
|
"docs/plan/compiler/2026-08-01-haxe-parity-language.md": {
|
||||||
|
"tasks": {
|
||||||
|
"111d92e6436d": {
|
||||||
|
"status": "doing",
|
||||||
|
"updatedAt": "2026-08-12T23:42:50Z"
|
||||||
|
},
|
||||||
|
"80e49fea186b": {
|
||||||
|
"status": "done",
|
||||||
|
"updatedAt": "2026-08-12T23:42:38Z"
|
||||||
|
},
|
||||||
|
"84addb11f795": {
|
||||||
|
"status": "done",
|
||||||
|
"updatedAt": "2026-08-12T23:42:35Z"
|
||||||
|
},
|
||||||
|
"942b4a1e8925": {
|
||||||
|
"status": "done",
|
||||||
|
"updatedAt": "2026-08-12T23:42:15Z"
|
||||||
|
},
|
||||||
|
"ea0f91fca71b": {
|
||||||
|
"status": "done",
|
||||||
|
"updatedAt": "2026-08-12T23:42:40Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -1004,10 +1004,55 @@ type Order { status: Pending | Paid | Shipped }
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn article_debug() {
|
fn article_debug() {
|
||||||
let src = std::fs::read_to_string(concat!(
|
// formerly read docs/examples/blog/types/article.wo; the blog sample
|
||||||
env!("CARGO_MANIFEST_DIR"), "/../../docs/examples/blog/types/article.wo"
|
// moved to the cleanup branch, so the fixture lives inline now —
|
||||||
)).unwrap();
|
// same shape: scalars, embedded doc, edges, backlink, computed,
|
||||||
let sch = parse(&src).unwrap();
|
// 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());
|
eprintln!("types: {}", sch.types.len());
|
||||||
for t in &sch.types {
|
for t in &sch.types {
|
||||||
eprintln!(" type {} — {} fields, {} services", t.name, t.fields.len(), t.services.len());
|
eprintln!(" type {} — {} fields, {} services", t.name, t.fields.len(), t.services.len());
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""agent-loop — a complete agent in one file, on a local model.
|
|
||||||
|
|
||||||
The whole trick behind Claude Code, opencode, Cursor and every other
|
|
||||||
"agentic" tool is one loop:
|
|
||||||
|
|
||||||
send the transcript + tool schemas to the model
|
|
||||||
while the model answers with tool calls:
|
|
||||||
run the tools, append the results to the transcript
|
|
||||||
send again
|
|
||||||
print the final text
|
|
||||||
|
|
||||||
Everything else those tools add (permissions, context management,
|
|
||||||
sub-agents) is elaboration on that loop. This file IS the loop, small
|
|
||||||
enough to read in one sitting: an OpenAI-compatible chat endpoint
|
|
||||||
(llama-server from prototypes/llama-moe-stream, or Ollama) + three
|
|
||||||
read-only repo tools + the guards that keep the loop alive when the
|
|
||||||
model misbehaves.
|
|
||||||
|
|
||||||
Run (one-shot): python3 agent.py "what does crates/rt/src/shard.rs do?"
|
|
||||||
Run (interactive): python3 agent.py
|
|
||||||
|
|
||||||
Configuration (environment):
|
|
||||||
LLM_URL OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
|
|
||||||
LLM_MODEL model name / alias (default qwen3-coder)
|
|
||||||
LLM_TIMEOUT per-request timeout in seconds (default 300)
|
|
||||||
AGENT_ROOT directory the tools may touch (default: current directory)
|
|
||||||
MAX_TURNS tool rounds per user message (default 20)
|
|
||||||
|
|
||||||
Dependencies: Python standard library only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
LLM_URL = os.environ.get("LLM_URL", "http://127.0.0.1:8080/v1").rstrip("/")
|
|
||||||
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3-coder")
|
|
||||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "300"))
|
|
||||||
ROOT = os.path.realpath(os.environ.get("AGENT_ROOT", os.getcwd()))
|
|
||||||
MAX_TURNS = int(os.environ.get("MAX_TURNS", "20"))
|
|
||||||
MAX_RESULT = 8_000 # chars of tool output fed back per call
|
|
||||||
SKIP_DIRS = {".git", "target", "node_modules", "build", "__pycache__", ".cache"}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- the tools
|
|
||||||
# Schemas are the contract shown to the model; implementations are the
|
|
||||||
# security boundary. Read-only on purpose — an agent is exactly as dangerous
|
|
||||||
# as its tools, never more.
|
|
||||||
|
|
||||||
TOOLS = [
|
|
||||||
{"type": "function", "function": {
|
|
||||||
"name": "list_dir",
|
|
||||||
"description": "List one directory: entries with a trailing / for "
|
|
||||||
"subdirectories and a byte size for files.",
|
|
||||||
"parameters": {"type": "object", "properties": {
|
|
||||||
"path": {"type": "string",
|
|
||||||
"description": "directory, relative to the project root"},
|
|
||||||
}, "required": ["path"]}}},
|
|
||||||
{"type": "function", "function": {
|
|
||||||
"name": "read_file",
|
|
||||||
"description": "Read a text file with line numbers. Large files are "
|
|
||||||
"windowed — pass offset (1-based first line) and limit "
|
|
||||||
"(max lines) to page through.",
|
|
||||||
"parameters": {"type": "object", "properties": {
|
|
||||||
"path": {"type": "string", "description": "file, relative to the project root"},
|
|
||||||
"offset": {"type": "integer", "description": "first line to show, 1-based (default 1)"},
|
|
||||||
"limit": {"type": "integer", "description": "max lines to show (default 200)"},
|
|
||||||
}, "required": ["path"]}}},
|
|
||||||
{"type": "function", "function": {
|
|
||||||
"name": "search",
|
|
||||||
"description": "Search file contents under a directory with a Python "
|
|
||||||
"regular expression. Returns path:line: text matches. "
|
|
||||||
"Use a specific pattern — results cap at 100 matches.",
|
|
||||||
"parameters": {"type": "object", "properties": {
|
|
||||||
"pattern": {"type": "string", "description": "Python regex to find"},
|
|
||||||
"path": {"type": "string", "description": "directory to search, relative to the project root (default: whole root)"},
|
|
||||||
}, "required": ["pattern"]}}},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ToolError(Exception):
|
|
||||||
"""A tool refusing to do something — reported to the model, never fatal."""
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve(path: str) -> str:
|
|
||||||
"""Confine every path the model asks for to ROOT (symlinks resolved)."""
|
|
||||||
full = os.path.realpath(os.path.join(ROOT, path))
|
|
||||||
if full != ROOT and not full.startswith(ROOT + os.sep):
|
|
||||||
raise ToolError(f"path escapes the project root: {path}")
|
|
||||||
return full
|
|
||||||
|
|
||||||
|
|
||||||
def list_dir(path: str = ".") -> str:
|
|
||||||
full = _resolve(path)
|
|
||||||
if not os.path.isdir(full):
|
|
||||||
raise ToolError(f"not a directory: {path}")
|
|
||||||
rows = []
|
|
||||||
for name in sorted(os.listdir(full)):
|
|
||||||
p = os.path.join(full, name)
|
|
||||||
rows.append(f"{name}/" if os.path.isdir(p)
|
|
||||||
else f"{name} ({os.path.getsize(p)} bytes)")
|
|
||||||
return "\n".join(rows) or "(empty directory)"
|
|
||||||
|
|
||||||
|
|
||||||
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
|
|
||||||
full = _resolve(path)
|
|
||||||
if os.path.isdir(full):
|
|
||||||
raise ToolError(f"{path} is a directory — use list_dir")
|
|
||||||
try:
|
|
||||||
with open(full, errors="replace") as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise ToolError(f"no such file: {path}")
|
|
||||||
if not lines:
|
|
||||||
return "(empty file)"
|
|
||||||
offset = max(1, int(offset))
|
|
||||||
limit = max(1, min(int(limit), 1000))
|
|
||||||
window = lines[offset - 1: offset - 1 + limit]
|
|
||||||
if not window:
|
|
||||||
raise ToolError(f"{path} has only {len(lines)} lines; offset {offset} is past the end")
|
|
||||||
out = "".join(f"{i}\t{line}" for i, line in enumerate(window, offset))
|
|
||||||
last = offset + len(window) - 1
|
|
||||||
if last < len(lines):
|
|
||||||
out += (f"\n[agent] showing lines {offset}-{last} of {len(lines)} — "
|
|
||||||
f"call again with offset={last + 1} for more")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def search(pattern: str, path: str = ".") -> str:
|
|
||||||
try:
|
|
||||||
rx = re.compile(pattern)
|
|
||||||
except re.error as e:
|
|
||||||
raise ToolError(f"bad regex {pattern!r}: {e}")
|
|
||||||
start = _resolve(path)
|
|
||||||
hits: list[str] = []
|
|
||||||
for dirpath, dirnames, filenames in os.walk(start): # never follows symlinks
|
|
||||||
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
||||||
for fname in sorted(filenames):
|
|
||||||
full = os.path.join(dirpath, fname)
|
|
||||||
if os.path.islink(full) or os.path.getsize(full) > 2_000_000:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
with open(full, errors="replace") as f:
|
|
||||||
head = f.read(1024)
|
|
||||||
if "\0" in head: # binary — skip
|
|
||||||
continue
|
|
||||||
f.seek(0)
|
|
||||||
for i, line in enumerate(f, 1):
|
|
||||||
if rx.search(line):
|
|
||||||
rel = os.path.relpath(full, ROOT)
|
|
||||||
hits.append(f"{rel}:{i}: {line.rstrip()[:200]}")
|
|
||||||
if len(hits) >= 100:
|
|
||||||
hits.append("[agent] 100-match cap hit — tighten the pattern or narrow the path")
|
|
||||||
return "\n".join(hits)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
return "\n".join(hits) or f"no matches for {pattern!r} under {path}"
|
|
||||||
|
|
||||||
|
|
||||||
TOOL_IMPLS = {"list_dir": list_dir, "read_file": read_file, "search": search}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------- executing calls
|
|
||||||
# The one rule that keeps the loop alive: NEVER raise at the model. Whatever
|
|
||||||
# goes wrong — unknown tool, broken JSON, missing file — comes back as the
|
|
||||||
# tool result, in words. A tool-calling model reads the error and corrects
|
|
||||||
# itself on the next round; an exception would just kill the conversation.
|
|
||||||
|
|
||||||
def execute(call: dict) -> str:
|
|
||||||
fn_block = call.get("function") or {}
|
|
||||||
name = fn_block.get("name", "")
|
|
||||||
impl = TOOL_IMPLS.get(name)
|
|
||||||
if impl is None:
|
|
||||||
return f"[agent] unknown tool {name!r} — available: {', '.join(TOOL_IMPLS)}"
|
|
||||||
try:
|
|
||||||
args = json.loads(fn_block.get("arguments") or "{}")
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
return f"[agent] arguments were not valid JSON ({e}) — resend the call with corrected JSON"
|
|
||||||
if not isinstance(args, dict):
|
|
||||||
return "[agent] arguments must be a JSON object"
|
|
||||||
try:
|
|
||||||
out = impl(**args)
|
|
||||||
except ToolError as e:
|
|
||||||
return f"[agent] {e}"
|
|
||||||
except TypeError as e:
|
|
||||||
return f"[agent] bad arguments for {name}: {e}"
|
|
||||||
except OSError as e:
|
|
||||||
return f"[agent] {name} failed: {e}"
|
|
||||||
if len(out) > MAX_RESULT:
|
|
||||||
out = (out[:MAX_RESULT] + f"\n[agent] truncated — {len(out)} chars total; "
|
|
||||||
"narrow the request (offset/limit, tighter pattern)")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ the loop
|
|
||||||
|
|
||||||
def chat(messages: list[dict]) -> dict:
|
|
||||||
"""One request to the model. Returns the assistant message verbatim."""
|
|
||||||
body = json.dumps({
|
|
||||||
"model": LLM_MODEL,
|
|
||||||
"messages": messages,
|
|
||||||
"tools": TOOLS,
|
|
||||||
"tool_choice": "auto",
|
|
||||||
}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{LLM_URL}/chat/completions",
|
|
||||||
data=body,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
|
||||||
data = json.load(resp)
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
detail = e.read().decode(errors="replace")[:400]
|
|
||||||
raise SystemExit(f"the model endpoint rejected the request (HTTP {e.code}): {detail}")
|
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
||||||
raise SystemExit(
|
|
||||||
f"cannot reach the model at {LLM_URL} ({e}).\n"
|
|
||||||
"Start one first:\n"
|
|
||||||
" prototypes/llama-moe-stream/start-local-agents.sh # llama-server on :8080\n"
|
|
||||||
" LLM_URL=http://localhost:11434/v1 LLM_MODEL=qwen3:4b … # or a pulled Ollama model")
|
|
||||||
return data["choices"][0]["message"]
|
|
||||||
|
|
||||||
|
|
||||||
def run_turn(messages: list[dict]) -> str:
|
|
||||||
"""THE AGENT LOOP. Everything above exists to serve these few lines."""
|
|
||||||
for _ in range(MAX_TURNS):
|
|
||||||
msg = chat(messages)
|
|
||||||
messages.append(msg) # the transcript is the only state
|
|
||||||
calls = msg.get("tool_calls") or []
|
|
||||||
if not calls: # no tool call = the model is done
|
|
||||||
return _strip_think(msg.get("content") or "")
|
|
||||||
for call in calls:
|
|
||||||
fn = call.get("function") or {}
|
|
||||||
print(f" ⚙ {fn.get('name', '?')}({(fn.get('arguments') or '')[:120]})",
|
|
||||||
file=sys.stderr)
|
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": call.get("id", ""),
|
|
||||||
"content": execute(call),
|
|
||||||
})
|
|
||||||
return (f"[agent] stopped after {MAX_TURNS} tool rounds without a final answer — "
|
|
||||||
"ask a narrower question or raise MAX_TURNS")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_think(text: str) -> str:
|
|
||||||
# Reasoning models may inline chain-of-thought as <think>…</think>;
|
|
||||||
# the user wants the conclusion, not the scratchpad.
|
|
||||||
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------- the shell
|
|
||||||
|
|
||||||
SYSTEM = (f"You are a code assistant working inside the project rooted at {ROOT}. "
|
|
||||||
"Use the tools to look at real files before answering; never invent "
|
|
||||||
"file contents or paths. When you have enough evidence, answer "
|
|
||||||
"concisely and cite locations as path:line.")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
messages: list[dict] = [{"role": "system", "content": SYSTEM}]
|
|
||||||
if len(sys.argv) > 1: # one-shot
|
|
||||||
messages.append({"role": "user", "content": " ".join(sys.argv[1:])})
|
|
||||||
print(run_turn(messages))
|
|
||||||
return
|
|
||||||
print(f"agent-loop — model {LLM_MODEL} at {LLM_URL}\n"
|
|
||||||
f"root {ROOT} (Ctrl-D to exit; the conversation persists across questions)")
|
|
||||||
while True: # interactive
|
|
||||||
try:
|
|
||||||
line = input("\nyou> ").strip()
|
|
||||||
except EOFError:
|
|
||||||
print()
|
|
||||||
return
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
messages.append({"role": "user", "content": line})
|
|
||||||
print(run_turn(messages))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
# `blog` — a sample writeonce app
|
|
||||||
|
|
||||||
A complete blogging website in **~200 lines of `.wo`** that creates a database, exposes REST + live-subscription endpoints, renders HTML pages, enforces row-level policies, and emits typed client SDKs.
|
|
||||||
|
|
||||||
> This project is a **docs artifact** — it illustrates the shape of a real `wo init`'d project. The `wo` toolchain referenced here is the one specified in [`../../runtime/wo-language.md`](../../runtime/wo-language.md); the engine is at prototype stage in [`../../../prototypes/wo-db/`](../../../prototypes/wo-db/).
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
| Thing | How |
|
|
||||||
| --- | --- |
|
|
||||||
| Persists articles, authors, tags, comments | `type` declarations compiled to relational rows + embedded documents + graph edges |
|
|
||||||
| Serves 24 REST endpoints (CRUD + subscribe × 4 types) | `service rest` blocks on each type |
|
|
||||||
| Serves 4 web pages (list, detail, tag, admin) | `##ui` screens + route table in `app.wo` |
|
|
||||||
| Pushes live updates on every commit | `live: true` on screens + `LIVE` queries under the hood |
|
|
||||||
| Enforces "drafts hidden from anonymous readers" | `policy read anyone when published == true` |
|
|
||||||
| Bumps `published_at` automatically | `on update` trigger inside the transaction |
|
|
||||||
| Generates a typed Go client | `wo gen sdk --lang go` |
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
blog/
|
|
||||||
├── wo.toml # project manifest (like go.mod)
|
|
||||||
├── app.wo # routes, theme, startup hooks
|
|
||||||
├── types/
|
|
||||||
│ ├── author.wo # Author type + per-type service/policy
|
|
||||||
│ ├── article.wo # Article — all three paradigms in one type
|
|
||||||
│ ├── tag.wo # Tag taxonomy
|
|
||||||
│ └── comment.wo # Reader comments
|
|
||||||
├── styles/
|
|
||||||
│ ├── main.css # global stylesheet (linked from app.wo styles:)
|
|
||||||
│ └── code-theme.css # syntax highlighting tokens
|
|
||||||
├── ui/
|
|
||||||
│ ├── article_list.wo # home page list view (live)
|
|
||||||
│ ├── article_detail.wo # per-article page with comments + related
|
|
||||||
│ └── components/ # reusable components (.wo + .htmlx + .css per component)
|
|
||||||
│ ├── article-card.wo # selector + typed inputs + styles:
|
|
||||||
│ ├── article-card.htmlx
|
|
||||||
│ ├── article-card.css
|
|
||||||
│ ├── comments.wo # selector + source + actions + role + styles:
|
|
||||||
│ ├── comments.htmlx
|
|
||||||
│ └── comments.css
|
|
||||||
└── tests/
|
|
||||||
└── article_test.wo # `wo test` picks this up
|
|
||||||
```
|
|
||||||
|
|
||||||
No `main.wo` is needed — a pure type+service app auto-generates its entry point. Add `main.wo` if you need CLI args, background workers, or custom startup logic beyond the `on startup` hook in `app.wo`.
|
|
||||||
|
|
||||||
### UI: components vs. screens
|
|
||||||
|
|
||||||
The `ui/` tree separates concerns the way Angular separates `@Component` / template / parent:
|
|
||||||
|
|
||||||
- **Screens** (`ui/article_list.wo`, `ui/article_detail.wo`) declare a `##ui` block — they own the route, the page-level data source, and the section layout. They embed components by selector via `use: <name>` + `with: { ... }` and pass typed inputs.
|
|
||||||
- **Components** (`ui/components/*.wo`) declare a `##component` block with `template: 'foo.htmlx'`, typed `inputs:`, and — when the component owns its own query — its `source:`, `sort:`, `live:`, and `actions:`. No HTML.
|
|
||||||
- **Templates** (`ui/components/*.htmlx`) are pure presentation. They read from the component's `inputs` and from the rows produced by its `source`. No data-source declarations, no role checks.
|
|
||||||
|
|
||||||
Screens never inline a component's HTML or its query; templates never declare data sources. Each `.wo` paired with one `.htmlx` is the unit of UI reuse.
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
|
|
||||||
CSS is declared at two scopes; in both cases the compiler emits the `<link>` tags into the SSR layout and serves the files under `/static/`:
|
|
||||||
|
|
||||||
- **App-level (global)** — `##app styles: [...]` in `app.wo` lists global stylesheets. Resolved relative to `./styles/`. Linked once, in declaration order, on every page.
|
|
||||||
- **Component-scoped** — `##component styles: [...]` lists CSS files alongside the component. The compiler rewrites bare selectors in those files to `[data-component="<selector>"] <rule>`, using the `data-component` attribute the templates already emit. Rules cannot leak outside the component subtree, so two components can both declare `.title` without colliding.
|
|
||||||
|
|
||||||
A component's CSS is only fetched on pages that embed the component. Global styles always load. Neither layer requires a build step — `wo run` serves the files as-is.
|
|
||||||
|
|
||||||
## Run it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ cd docs/examples/blog
|
|
||||||
$ wo run
|
|
||||||
[wo] parsing: 7 files, 4 types, 2 ui screens
|
|
||||||
[wo] compiling schema: 4 sql tables, 1 doc collection, 3 graph edge types
|
|
||||||
[wo] starting runtime (engine: in-memory, data_dir: ./data)
|
|
||||||
[wo] on startup: seed_admin() — inserted admin@example.com
|
|
||||||
[wo] HTTP listening on :8080
|
|
||||||
|
|
||||||
GET /api/articles list
|
|
||||||
GET /api/articles/:id get
|
|
||||||
POST /api/articles create
|
|
||||||
PATCH /api/articles/:id update
|
|
||||||
DELETE /api/articles/:id delete
|
|
||||||
WS /api/articles/live subscribe
|
|
||||||
GET /api/authors list
|
|
||||||
GET /api/authors/me me
|
|
||||||
WS /api/authors/live subscribe
|
|
||||||
GET /api/tags list
|
|
||||||
GET /api/comments list
|
|
||||||
POST /api/comments create
|
|
||||||
WS /api/comments/live subscribe
|
|
||||||
... (and the rest)
|
|
||||||
|
|
||||||
GET / ui.article-list
|
|
||||||
GET /article/:slug ui.article-detail
|
|
||||||
GET /tag/:slug ui.article-list (filtered)
|
|
||||||
GET /admin ui.article-list (role: Admin)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Exercise the REST API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create an author (requires admin session — see auth docs; stub'd here for brevity)
|
|
||||||
$ curl -X POST localhost:8080/api/authors \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
||||||
-d '{"email":"alice@example.com","handle":"alice","display":"Alice","role":"Author"}'
|
|
||||||
{"id":2,"email":"alice@example.com","handle":"alice",...}
|
|
||||||
|
|
||||||
# Create an article as that author
|
|
||||||
$ curl -X POST localhost:8080/api/articles \
|
|
||||||
-H "Authorization: Bearer $ALICE_TOKEN" \
|
|
||||||
-d '{
|
|
||||||
"slug": "hello",
|
|
||||||
"title": "Hello, writeonce",
|
|
||||||
"author": 2,
|
|
||||||
"meta": {"excerpt":"First post","body_md":"# Hi\n\nHello."},
|
|
||||||
"published": true
|
|
||||||
}'
|
|
||||||
{"id":1,"slug":"hello","title":"Hello, writeonce","published_at":"2026-04-17T12:00:00Z",...}
|
|
||||||
|
|
||||||
# List published articles (public — no token)
|
|
||||||
$ curl localhost:8080/api/articles
|
|
||||||
[{"id":1,"slug":"hello","title":"Hello, writeonce",...}]
|
|
||||||
|
|
||||||
# Filter by tag (via the query layer)
|
|
||||||
$ curl 'localhost:8080/api/articles?tags.slug=rust'
|
|
||||||
[...]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Subscribe to live updates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ websocat ws://localhost:8080/api/articles/live?published=eq.true
|
|
||||||
{"kind":"snapshot","rows":[{"id":1,"slug":"hello",...}]}
|
|
||||||
|
|
||||||
# Now in another terminal, update article 1. The open socket receives:
|
|
||||||
{"kind":"update","id":1,"old":{"title":"Hello, writeonce"},"new":{"title":"Hello!"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
No polling. The subscription predicate was registered at connect time; the engine's commit path emits the delta directly.
|
|
||||||
|
|
||||||
## Generate a Go client
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ wo gen sdk --lang go --out ./client
|
|
||||||
[wo] reading types from ./types/
|
|
||||||
[wo] writing ./client/sdk.go (4 types, 16 endpoints, 4 subscriptions)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use it:
|
|
||||||
|
|
||||||
```go
|
|
||||||
import "github.com/you/blog/client"
|
|
||||||
|
|
||||||
c, _ := client.Connect(ctx, "wo://localhost:8080", client.WithToken(token))
|
|
||||||
|
|
||||||
// Typed query
|
|
||||||
articles, _ := c.Articles.List(ctx, client.Where{Published: ptr(true)})
|
|
||||||
|
|
||||||
// Typed subscription — deltas arrive on a channel
|
|
||||||
sub, _ := c.Articles.Subscribe(ctx, client.Where{Published: ptr(true)})
|
|
||||||
for d := range sub.C {
|
|
||||||
switch d.Kind {
|
|
||||||
case client.Insert:
|
|
||||||
fmt.Printf("new article: %s\n", d.Row.Title)
|
|
||||||
case client.Update:
|
|
||||||
fmt.Printf("updated: %s\n", d.Row.Slug)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run the tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ wo test
|
|
||||||
=== tests/article_test.wo ===
|
|
||||||
create and fetch by slug OK (3ms)
|
|
||||||
policy blocks public read of unpublished drafts OK (4ms)
|
|
||||||
graph traversal: related articles OK (7ms)
|
|
||||||
live subscription receives delta on commit OK (12ms)
|
|
||||||
|
|
||||||
PASS 4/4 tests, 0 failures (26ms)
|
|
||||||
```
|
|
||||||
|
|
||||||
Each `test` block runs against an isolated engine snapshot that's rolled back at the end — no setup/teardown code needed.
|
|
||||||
|
|
||||||
## Build a production binary
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ wo build --target linux-amd64 --out bin/blog
|
|
||||||
[wo] static binary: bin/blog (14 MB, database + HTTP + subscription engine embedded)
|
|
||||||
$ ./bin/blog
|
|
||||||
[wo] HTTP listening on :8080
|
|
||||||
```
|
|
||||||
|
|
||||||
One binary, no dependencies. Copy it to a server, run it, done. The database file lives in `./data/` relative to the binary; the WAL ensures crash safety ([Phase 3](../../runtime/database/03-inmemory-engine.md)).
|
|
||||||
|
|
||||||
## What to read next
|
|
||||||
|
|
||||||
- [`../../runtime/wo-language.md`](../../runtime/wo-language.md) — the user-facing language overview this project builds on
|
|
||||||
- [`../../runtime/database/02-wo-language.md`](../../runtime/database/02-wo-language.md) — the two-layer language spec (schema + query layers)
|
|
||||||
- [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) — the `##ui`/`##policy`/`##service`/`##app` block spec
|
|
||||||
- [`../../../prototypes/wo-db/`](../../../prototypes/wo-db/) — the C++ prototype that runs the query-layer subset today
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
-- Root manifest. Names the app, maps URL paths to UI screens, and configures
|
|
||||||
-- project-wide concerns (theme, i18n). The compiler uses this to assemble the
|
|
||||||
-- static route table and the SSR renderer.
|
|
||||||
|
|
||||||
##app
|
|
||||||
name: "blog"
|
|
||||||
version: 1
|
|
||||||
theme: "light"
|
|
||||||
i18n: [en]
|
|
||||||
|
|
||||||
-- Global stylesheets. Resolved relative to ./styles/, served under
|
|
||||||
-- /static/styles/, and emitted as <link> tags in the SSR layout in this
|
|
||||||
-- order. Component-scoped CSS lives next to the component (see ui/components).
|
|
||||||
styles:
|
|
||||||
- styles/main.css
|
|
||||||
- styles/code-theme.css
|
|
||||||
|
|
||||||
-- URL → UI screen binding. `:slug` is a dynamic path segment that binds to a
|
|
||||||
-- parameter visible inside the screen as `$slug`.
|
|
||||||
routes:
|
|
||||||
/ -> ui.article-list
|
|
||||||
/article/:slug -> ui.article-detail { key: $slug }
|
|
||||||
/tag/:slug -> ui.article-list { filter: { tags.slug == $slug } }
|
|
||||||
/admin -> ui.article-list { role: Admin }
|
|
||||||
|
|
||||||
-- Project-wide policies — applied on every query, AND-ed with any type-level
|
|
||||||
-- policy. Useful for ops-level toggles like admin bypass.
|
|
||||||
policy admin-bypass
|
|
||||||
applies_to: Article, Comment, Author, Tag
|
|
||||||
when: $session.role == Admin
|
|
||||||
effect: skip-row-filters
|
|
||||||
|
|
||||||
-- Lifecycle hooks. `on startup` runs once before the HTTP server binds;
|
|
||||||
-- convenient for idempotent seeding.
|
|
||||||
on startup
|
|
||||||
do: seed_admin()
|
|
||||||
|
|
||||||
-- Inline function — available to triggers and lifecycle hooks.
|
|
||||||
fn seed_admin() {
|
|
||||||
if count(Author{ role == Admin }) == 0 {
|
|
||||||
insert Author {
|
|
||||||
email: "admin@example.com",
|
|
||||||
handle: "admin",
|
|
||||||
display: "Admin",
|
|
||||||
role: Admin
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<article class="article-card" data-component="article-card" data-id="{{article.id}}">
|
|
||||||
<h2 class="article-card-title">
|
|
||||||
<a href="/article/{{article.slug}}">{{article.title}}</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="article-card-meta">
|
|
||||||
<span class="article-card-author">{{article.author.display}}</span>
|
|
||||||
<time datetime="{{article.published_at}}">{{article.published_at}}</time>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{#if article.meta.excerpt}}
|
|
||||||
<p class="article-card-excerpt">{{article.meta.excerpt}}</p>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if article.tags}}
|
|
||||||
<ul class="article-card-tags">
|
|
||||||
{{#each article.tags as tag}}
|
|
||||||
<li><a href="/tag/{{tag.slug}}">{{tag.name}}</a></li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
{{/if}}
|
|
||||||
</article>
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
/* Scoped to the article-comments component via the data-component attribute
|
|
||||||
* emitted by comments.htmlx. The compiler rewrites bare selectors below to
|
|
||||||
* [data-component="article-comments"] <selector>, so the rules cannot leak
|
|
||||||
* outside the component subtree. */
|
|
||||||
|
|
||||||
.comments-header h3 { font-size: 1rem; text-transform: uppercase; letter-spacing: 1px; color: #888; }
|
|
||||||
|
|
||||||
.comment-list { list-style: none; padding: 0; margin: 1rem 0; }
|
|
||||||
.comment { padding: 0.75rem 0; border-bottom: 1px solid #eee; }
|
|
||||||
.comment:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.comment-meta { display: flex; gap: 0.75rem; font-size: 0.85rem; color: #999; margin-bottom: 0.25rem; }
|
|
||||||
.comment-author { color: #555; font-weight: 600; }
|
|
||||||
.comment-body { margin: 0; }
|
|
||||||
|
|
||||||
.comment-form { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
.comment-form textarea { padding: 0.5rem; font-family: inherit; border: 1px solid #ddd; border-radius: 4px; resize: vertical; min-height: 4rem; }
|
|
||||||
.comment-form button { align-self: flex-start; padding: 0.4rem 1rem; background: #222; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
|
|
||||||
.comment-form button:hover { background: #0066cc; }
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<section class="comments" data-component="article-comments" data-article-id="{{article-id}}">
|
|
||||||
<header class="comments-header">
|
|
||||||
<h3>Comments</h3>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ul class="comment-list" data-live="comments">
|
|
||||||
{{#each comments as comment}}
|
|
||||||
<li class="comment" data-id="{{comment.id}}">
|
|
||||||
<div class="comment-meta">
|
|
||||||
<span class="comment-author">{{comment.author.display}}</span>
|
|
||||||
<time datetime="{{comment.created_at}}">{{comment.created_at}}</time>
|
|
||||||
</div>
|
|
||||||
<p class="comment-body">{{comment.body}}</p>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{{#when actions.create}}
|
|
||||||
<form class="comment-form" data-action="create">
|
|
||||||
<input type="hidden" name="article" value="{{article-id}}">
|
|
||||||
<textarea name="body" required placeholder="Write a comment…"></textarea>
|
|
||||||
<button type="submit">Post comment</button>
|
|
||||||
</form>
|
|
||||||
{{/when}}
|
|
||||||
</section>
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
# `ecommerce` — a sample writeonce **monorepo**
|
|
||||||
|
|
||||||
Two apps (customer **storefront** + ops **admin**) sharing one database, built from a common pool of types + business logic. Mirrors the Nx / Angular workspace pattern: `apps/*` for deployable binaries, `shared/*` for libraries imported across apps.
|
|
||||||
|
|
||||||
> This project is a **docs artifact** — illustrative `.wo` source showing what a production-shaped writeonce workspace looks like. The master plan for the compiler + client runtime + per-app build is at [`../../plan/ui/00-overview.md`](../../plan/exploration/ui/00-overview.md). Sub-phases UI/01–07 implement each piece.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
ecommerce/
|
|
||||||
├── wo.toml # workspace manifest (apps[] + shared[] + database)
|
|
||||||
├── README.md # this file
|
|
||||||
│
|
|
||||||
├── shared/ # code imported by one or more apps
|
|
||||||
│ ├── types/
|
|
||||||
│ │ ├── customer.wo # role union, policy, service rest expose
|
|
||||||
│ │ ├── product.wo # inventory + similar_to graph
|
|
||||||
│ │ ├── order.wo # tagged-union status + line_items array
|
|
||||||
│ │ └── purchase.wo # link Customer -> Product
|
|
||||||
│ ├── logic/
|
|
||||||
│ │ ├── checkout.wo # fn checkout / mark_paid / mark_shipped
|
|
||||||
│ │ └── seed.wo # on-startup demo seed + admin-ops bypass policy
|
|
||||||
│ └── components/ # reusable .htmlx partials (forward-looking — UI track)
|
|
||||||
│ ├── layout.htmlx # page chrome shared across apps
|
|
||||||
│ ├── money.htmlx # {{> money amount=total}}
|
|
||||||
│ └── order-row.htmlx # used by both orders tables
|
|
||||||
│
|
|
||||||
├── apps/ # one binary per app
|
|
||||||
│ ├── storefront/ # customer-facing
|
|
||||||
│ │ ├── wo.toml # listen :8080, connect WO_DB
|
|
||||||
│ │ ├── app.wo # routes: / → home, /product/:sku, /cart, /orders
|
|
||||||
│ │ └── ui/
|
|
||||||
│ │ ├── home/
|
|
||||||
│ │ │ └── home.wo # product list, live inventory
|
|
||||||
│ │ ├── product-detail/ # (future)
|
|
||||||
│ │ └── orders/
|
|
||||||
│ │ └── orders.wo # customer's own orders
|
|
||||||
│ │
|
|
||||||
│ └── admin/ # ops dashboard
|
|
||||||
│ ├── wo.toml # listen :8081, connect WO_DB
|
|
||||||
│ ├── app.wo # role: Admin | Ops; routes: /orders
|
|
||||||
│ └── ui/
|
|
||||||
│ └── orders/
|
|
||||||
│ └── orders.wo # live ops table with fulfillment actions
|
|
||||||
│
|
|
||||||
└── tests/ # workspace-level integration
|
|
||||||
└── checkout_test.wo
|
|
||||||
```
|
|
||||||
|
|
||||||
Each app's UI screens live in their own directory (`apps/<app>/ui/<screen>/`) with the Angular-style one-directory-per-component pattern — `.wo` declarative spec today, `.htmlx` template + `.css` stylesheet once the UI track's sub-phase 01–02 land.
|
|
||||||
|
|
||||||
## What the two apps share
|
|
||||||
|
|
||||||
- **Types** (`shared/types/`). Both apps see the same `Customer` / `Product` / `Order` / `Purchase` definitions. Row-level policies inside each `type` block control who sees what — the storefront's authenticated customer sees their own orders; the admin app's Admin/Ops role sees everyone's.
|
|
||||||
- **Logic** (`shared/logic/`). The `checkout`, `mark_paid`, `mark_shipped`, and `release_inventory` functions in `shared/logic/checkout.wo` are callable from either app (subject to role policy). `shared/logic/seed.wo` runs once when the shared DB daemon starts.
|
|
||||||
- **Components** (`shared/components/`). `.htmlx` partials — layout chrome, money formatting, an order-row renderer — reusable from either app's templates.
|
|
||||||
|
|
||||||
## What's **not** shared (per-app)
|
|
||||||
|
|
||||||
- **`app.wo`** declares app-specific routes + role gate. Storefront has no `/admin/*` routes; admin has no `/cart` or `/product/:sku`.
|
|
||||||
- **`ui/`** is per-app. The storefront's `orders/orders.wo` (customer's own orders, filtered by session) and the admin's `orders/orders.wo` (all orders with fulfillment actions) are different screens — same underlying `Order` type, different UI + policy scope.
|
|
||||||
- Each app's `wo.toml` names its own listen port and its own DB API key.
|
|
||||||
|
|
||||||
## Running it
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Start the shared DB daemon — headless, just the engine + wire protocol
|
|
||||||
wo db serve --data-dir ./data # listens on wo://127.0.0.1:5555
|
|
||||||
|
|
||||||
# 2. Start each app, pointing at the daemon
|
|
||||||
WO_DB=wo://127.0.0.1:5555 \
|
|
||||||
STOREFRONT_DB_KEY=$ADMIN_TOKEN \
|
|
||||||
wo run apps/storefront # HTTP on :8080
|
|
||||||
|
|
||||||
WO_DB=wo://127.0.0.1:5555 \
|
|
||||||
ADMIN_DB_KEY=$ADMIN_TOKEN \
|
|
||||||
wo run apps/admin # HTTP on :8081
|
|
||||||
```
|
|
||||||
|
|
||||||
Browser:
|
|
||||||
- `http://localhost:8080/` → storefront home (product list, live inventory)
|
|
||||||
- `http://localhost:8081/orders` → admin live orders table
|
|
||||||
|
|
||||||
## Building binaries
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wo build apps/storefront # → target/wo/storefront
|
|
||||||
wo build apps/admin # → target/wo/admin
|
|
||||||
wo build --all # everything in apps/
|
|
||||||
```
|
|
||||||
|
|
||||||
Each binary is a static ELF with only the app's own `app.wo` + `ui/` + the `shared/` it imports baked in. Drop any binary on a server next to a running `wo db serve` and it works.
|
|
||||||
|
|
||||||
## Stage 2 caveat
|
|
||||||
|
|
||||||
The current runtime at [`crates/rt/`](../../../crates/rt/) is a single-process Stage 2 prototype — it doesn't yet know about:
|
|
||||||
|
|
||||||
- `[workspace]` manifests (per-app build — UI sub-phase 05)
|
|
||||||
- The `wo db serve` daemon split (UI sub-phase 06)
|
|
||||||
- Per-app `api_key_env` authorisation scope (UI sub-phase 07)
|
|
||||||
- `.htmlx` compilation from `##ui` blocks (UI sub-phases 01–03)
|
|
||||||
- Per-app policy composition (UI sub-phase 07)
|
|
||||||
|
|
||||||
So `cargo run --bin wo -- run docs/examples/ecommerce` today walks the whole tree, finds every `.wo` file under `shared/` + `apps/`, parses the types, and serves the union REST API on :8080 — treating the monorepo as one giant app. Useful for exercising the types; not reflective of the production shape. See [`docs/plan/ui/00-overview.md`](../../plan/exploration/ui/00-overview.md) for the sub-phase sequence that gets each piece online.
|
|
||||||
|
|
||||||
## Comparison with the blog sample
|
|
||||||
|
|
||||||
The [`blog` sample](../blog/) is still a single-app layout (`types/`, `ui/`, `logic/` at the root) because the blog has exactly one front-end surface — there's no customer-vs-admin split. Both patterns are first-class; pick based on whether your schema serves one app or many. A flat single-app layout is a degenerate workspace with one `apps/` entry.
|
|
||||||
|
|
||||||
## Source pointers
|
|
||||||
|
|
||||||
- **Master plan:** [`../../plan/ui/00-overview.md`](../../plan/exploration/ui/00-overview.md)
|
|
||||||
- **Language spec the `##ui`/`##app`/`policy` blocks obey:** [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md)
|
|
||||||
- **Wire protocol the app binaries speak to the DB daemon:** [`../../runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md)
|
|
||||||
- **v1 template engine that `.htmlx` compilation will reuse:** [`../../../.dev/reference/crates/wo-htmlx/`](../../../.dev/reference/crates/wo-htmlx/)
|
|
||||||
- **Checkout transaction that's the canonical cross-paradigm test:** [`shared/logic/checkout.wo`](shared/logic/checkout.wo)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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/
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
-- Storefront home — public product list. Live inventory: when a checkout
|
|
||||||
-- reserves the last unit, the "In stock" badge flips to "Out of stock" on
|
|
||||||
-- every open browser without refresh.
|
|
||||||
--
|
|
||||||
-- Angular-component-style layout: home/{home.wo, home.htmlx, home.css}.
|
|
||||||
-- home.wo is the declarative spec; home.htmlx (when added) lets an author
|
|
||||||
-- hand-tune the template while the compiler still generates the
|
|
||||||
-- <wo:live> + wo:bind subscription glue. See
|
|
||||||
-- [docs/plan/ui/00-overview.md](../../../../../plan/ui/00-overview.md).
|
|
||||||
|
|
||||||
##ui
|
|
||||||
#home
|
|
||||||
title: "Shop"
|
|
||||||
source: Product
|
|
||||||
live: true
|
|
||||||
|
|
||||||
columns:
|
|
||||||
- meta.images[0] label: "" renderer: image
|
|
||||||
- name
|
|
||||||
- sku renderer: code
|
|
||||||
- price renderer: money
|
|
||||||
- available label: "In stock" renderer: stock-badge -- computed on Product
|
|
||||||
|
|
||||||
sort:
|
|
||||||
default: name asc
|
|
||||||
|
|
||||||
actions:
|
|
||||||
row-click: /product/:sku
|
|
||||||
add-to-cart: add_to_cart(self, 1) role: Customer | Ops | Admin
|
|
||||||
|
|
||||||
pagination: 24
|
|
||||||
|
|
@ -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: "/" }
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
{{!--
|
|
||||||
Top-level page chrome shared across every app in the workspace.
|
|
||||||
Both storefront and admin inherit this layout and override the `{{> slot.content}}`
|
|
||||||
partial with their own screen's body.
|
|
||||||
|
|
||||||
The `wo:bind="session.user.name"` in the header is a field-level live binding:
|
|
||||||
when the customer updates their profile, every open tab's header repaints
|
|
||||||
without a page reload — the same delta-dispatch mechanism the live tables use,
|
|
||||||
just on a singleton subscription.
|
|
||||||
|
|
||||||
See docs/plan/ui/01-htmlx-format-spec.md (to land in a future sub-phase)
|
|
||||||
for the full grammar.
|
|
||||||
--}}
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="{{app.i18n}}">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>{{page.title}} — {{app.name}}</title>
|
|
||||||
<link rel="stylesheet" href="/static/{{app.name}}.css">
|
|
||||||
{{> slot.head}}
|
|
||||||
</head>
|
|
||||||
<body class="theme-{{app.theme}}">
|
|
||||||
<header class="page-header">
|
|
||||||
<a href="/" class="brand">{{app.name}}</a>
|
|
||||||
<nav>{{> slot.nav}}</nav>
|
|
||||||
{{#if session.user}}
|
|
||||||
<span class="session" wo:bind="session.user.name">{{session.user.name}}</span>
|
|
||||||
{{/if}}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="page-body">
|
|
||||||
{{> slot.content}}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="page-footer">
|
|
||||||
<small>Built with writeonce · {{app.version}}</small>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{{!-- Client runtime + subscription manifest are injected automatically. --}}
|
|
||||||
<script data-wo-runtime src="/_wo/runtime.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{{!--
|
|
||||||
Tiny partial: renders a Money value (minor units — cents) as a localised
|
|
||||||
currency string. Called from any screen via `{{> money amount=total}}`.
|
|
||||||
See apps/admin/ui/orders/orders.wo columns block for a live example.
|
|
||||||
--}}
|
|
||||||
<span class="money">${{divide amount by 100}}.{{mod amount by 100 padleft 2 with "0"}}</span>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
{{!--
|
|
||||||
One order row — used by both apps' order tables. Different columns are
|
|
||||||
displayed depending on the caller, via conditional sub-partials; the
|
|
||||||
`wo:bind="status"` attribute makes the status cell live-update on delta
|
|
||||||
frames regardless of which table embeds this row.
|
|
||||||
|
|
||||||
Customer view (storefront/ui/orders/orders.wo): {{> order-row for="customer"}}
|
|
||||||
Admin view (admin/ui/orders/orders.wo): {{> order-row for="ops"}}
|
|
||||||
--}}
|
|
||||||
<tr class="order-row status-{{status}}" data-key="{{id}}">
|
|
||||||
<td class="order-id" wo:bind="id">{{id}}</td>
|
|
||||||
<td class="order-status" wo:bind="status">{{status}}</td>
|
|
||||||
{{#if (eq for "ops")}}
|
|
||||||
<td class="customer-name">{{customer.name}}</td>
|
|
||||||
<td class="customer-email">{{customer.email}}</td>
|
|
||||||
{{/if}}
|
|
||||||
<td class="order-total" wo:bind="total">{{> money amount=total}}</td>
|
|
||||||
<td class="order-placed" wo:bind="placed_at">{{relative placed_at}}</td>
|
|
||||||
{{#if (eq for "ops")}}
|
|
||||||
<td class="order-paid" wo:bind="paid_at">{{relative paid_at}}</td>
|
|
||||||
<td class="order-shipped" wo:bind="shipped_at">{{relative shipped_at}}</td>
|
|
||||||
{{/if}}
|
|
||||||
</tr>
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""mcp-think — offload thinking to a LOCAL model, exposed to Claude over MCP.
|
|
||||||
|
|
||||||
A minimal MCP server (stdio transport) whose tools run entirely on your
|
|
||||||
machine: each tool sends a prompt to a local model served by Ollama and
|
|
||||||
returns the answer. Nothing in the tool inputs ever leaves the box.
|
|
||||||
|
|
||||||
Why offload to a local model from Claude?
|
|
||||||
* privacy — summarize/critique content that must not leave the machine
|
|
||||||
* cost — bulk work (summaries, drafts, idea generation) at zero token cost
|
|
||||||
* diversity — a second opinion from a different model family
|
|
||||||
* bandwidth — Claude stays on the main task while the local model grinds
|
|
||||||
|
|
||||||
Run (Claude Code):
|
|
||||||
claude mcp add think -- uv run --with mcp python3 /abs/path/to/server.py
|
|
||||||
|
|
||||||
Configuration (environment):
|
|
||||||
OLLAMA_URL base URL of the Ollama daemon (default http://localhost:11434)
|
|
||||||
THINK_MODEL model to use (default qwen3:4b)
|
|
||||||
THINK_TIMEOUT per-call timeout in seconds (default 300)
|
|
||||||
|
|
||||||
Dependencies: the `mcp` package only (`uv run --with mcp` provisions it).
|
|
||||||
The Ollama call uses the Python standard library — no SDK, no httpx.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
# The SDK is renaming FastMCP → MCPServer; support both so the example works
|
|
||||||
# with the released `mcp` package today and with the renamed one later.
|
|
||||||
try:
|
|
||||||
from mcp.server.mcpserver import MCPServer as Server # unreleased SDK head
|
|
||||||
except ImportError:
|
|
||||||
from mcp.server.fastmcp import FastMCP as Server # mcp <= 1.x (PyPI)
|
|
||||||
|
|
||||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434").rstrip("/")
|
|
||||||
THINK_MODEL = os.environ.get("THINK_MODEL", "qwen3:4b")
|
|
||||||
THINK_TIMEOUT = int(os.environ.get("THINK_TIMEOUT", "300"))
|
|
||||||
|
|
||||||
mcp = Server("think")
|
|
||||||
|
|
||||||
|
|
||||||
def ask_local_model(system: str, prompt: str) -> str:
|
|
||||||
"""One chat round against the local model. Errors come back as text so
|
|
||||||
the calling model can read them and tell the user what to fix."""
|
|
||||||
body = json.dumps({
|
|
||||||
"model": THINK_MODEL,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
|
||||||
"stream": False,
|
|
||||||
}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{OLLAMA_URL}/api/chat",
|
|
||||||
data=body,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=THINK_TIMEOUT) as resp:
|
|
||||||
data = json.load(resp)
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
detail = e.read().decode(errors="replace")[:300]
|
|
||||||
return (f"[mcp-think] the local model rejected the request "
|
|
||||||
f"(HTTP {e.code}): {detail}\n"
|
|
||||||
f"Is the model pulled? Try: ollama pull {THINK_MODEL}")
|
|
||||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
||||||
return (f"[mcp-think] cannot reach the local model at {OLLAMA_URL} ({e}).\n"
|
|
||||||
f"Start it with `ollama serve`, pull the model with "
|
|
||||||
f"`ollama pull {THINK_MODEL}`, then retry.")
|
|
||||||
|
|
||||||
text = (data.get("message") or {}).get("content", "")
|
|
||||||
# Reasoning models may inline their chain of thought as <think>…</think>;
|
|
||||||
# strip it — the caller wants the conclusion, not the scratchpad.
|
|
||||||
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
|
||||||
return text or "[mcp-think] the local model returned an empty response"
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def think(task: str, context: str = "") -> str:
|
|
||||||
"""Reason through a problem on the local model and return its conclusion.
|
|
||||||
|
|
||||||
Call this to get an independent, private, zero-cost analysis of a
|
|
||||||
question — weighing a design trade-off, sanity-checking a plan, working
|
|
||||||
through logic — especially when a second opinion from a different model
|
|
||||||
family is valuable. `context` carries any background the task needs
|
|
||||||
(code, notes, constraints); it never leaves this machine.
|
|
||||||
"""
|
|
||||||
system = ("You are a careful reasoning assistant. Think the problem "
|
|
||||||
"through step by step, then answer with your conclusion first, "
|
|
||||||
"followed by the key reasoning in a few short paragraphs.")
|
|
||||||
prompt = f"{task}\n\n--- context ---\n{context}" if context else task
|
|
||||||
return ask_local_model(system, prompt)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def critique(work: str, focus: str = "") -> str:
|
|
||||||
"""Adversarially review a piece of work (code, prose, a plan, a design)
|
|
||||||
on the local model and return the strongest objections.
|
|
||||||
|
|
||||||
Call this before committing to a decision or shipping a draft, when an
|
|
||||||
independent devil's advocate is useful, or when the material is private
|
|
||||||
and must be reviewed without leaving the machine. `focus` optionally
|
|
||||||
narrows the review (e.g. "error handling", "argument structure").
|
|
||||||
"""
|
|
||||||
system = ("You are a rigorous, adversarial reviewer. Find the strongest "
|
|
||||||
"objections: errors, gaps, risks, unstated assumptions. Rank "
|
|
||||||
"them most-severe first. Be specific — point at the exact "
|
|
||||||
"part you object to and say why. Do not pad with praise.")
|
|
||||||
prompt = f"Review the following{f', focusing on {focus}' if focus else ''}:\n\n{work}"
|
|
||||||
return ask_local_model(system, prompt)
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def brainstorm(topic: str, n: int = 5) -> str:
|
|
||||||
"""Generate `n` distinct ideas on a topic using the local model.
|
|
||||||
|
|
||||||
Call this to widen the option space cheaply before converging — naming,
|
|
||||||
approaches, test cases, failure modes, feature ideas. The local model's
|
|
||||||
different training makes its ideas usefully different from yours.
|
|
||||||
"""
|
|
||||||
system = ("You are a prolific idea generator. Produce genuinely distinct "
|
|
||||||
"ideas — different mechanisms, not rephrasings. One line of "
|
|
||||||
"pitch plus one line of how it would work, per idea.")
|
|
||||||
return ask_local_model(system, f"Generate {n} distinct ideas for: {topic}")
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def summarize(text: str, max_words: int = 200) -> str:
|
|
||||||
"""Summarize text on the local model — the content never leaves this
|
|
||||||
machine and costs no API tokens.
|
|
||||||
|
|
||||||
Call this to condense large material (logs, documents, transcripts,
|
|
||||||
diffs) before reasoning about it, or when the content is sensitive and
|
|
||||||
must stay local. The summary comes back; the original stays here.
|
|
||||||
"""
|
|
||||||
system = (f"Summarize faithfully in at most {max_words} words. Keep "
|
|
||||||
"concrete facts, numbers, names, and conclusions; drop filler. "
|
|
||||||
"Note anything surprising or anomalous explicitly.")
|
|
||||||
return ask_local_model(system, text)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
mcp.run() # stdio — Claude launches this as a subprocess
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# `pricing` — class model + live pricing demo
|
|
||||||
|
|
||||||
A `Price` class and a `Product` class with methods — products have prices — and a `/pricing` screen that shows **live price data for selected products**. Data stays in RAM; one product is readable by millions of customers at once; a price update pushes live to millions of subscribers; all concurrency is Linux kernel I/O (epoll today, io_uring per the scale-out plan).
|
|
||||||
|
|
||||||
> **Status: 13a + 13b shipped.** `class` declarations parse, the demo serves real REST CRUD, **and methods execute over RPC** — `wo run docs/examples/pricing` (or `just pricing-demo`) serves `POST /api/products/:id/set_price` and `:id/current_price` as row-scoped transactions: the whole body commits as one WAL frame, an `assert … otherwise abort` rolls everything back (409). `subscribe` is a 501 stub until 13c; the UI is design-only until 13d/plan 14. The master plan is [`docs/plan/13-class-model-live-pricing.md`](../../plan/13-class-model-live-pricing.md).
|
|
||||||
|
|
||||||
## The class model in one paragraph
|
|
||||||
|
|
||||||
`class` = **state + methods, no inheritance**. Fields, defaults, `ref`/`multi`, `service`, `policy`, `on <event>` — all exactly as in `type` — plus `fn` methods with an implicit `self` receiver that run as row-scoped transactions (`in txn [snapshot]`, the same machinery as the ecommerce sample's free-standing `fn checkout`). No `extends`, no override, no virtual dispatch: "is-a" is a tagged union, "has-a" is a `ref`/`multi` edge. Go-style encapsulation, not Java-style hierarchies.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
The UI follows **MVC** ([`docs/plan/exploration/ui/08-mvc-structure.md`](../../plan/exploration/ui/08-mvc-structure.md)), with the same screen anatomy as the v1 Angular app (`reference/writeonce-app/src/app/article/`) collapsed into the single binary: the **model** is the class itself, the **view** is plain `.htmlx` with external `.scss`, and the **controller** is a `.wo` file that binds the model into the view and is the only place UI may call class methods.
|
|
||||||
|
|
||||||
```
|
|
||||||
pricing/
|
|
||||||
├── wo.toml # app manifest
|
|
||||||
├── main.wo # entry point: insert product, LIVE subscribe, set_price
|
|
||||||
├── types/ # MODEL — classes: schema + methods
|
|
||||||
│ ├── price.wo # class Price — amount/currency/at + fn discounted(pct)
|
|
||||||
│ └── product.wo # class Product — prices: multi Price + fn current_price /
|
|
||||||
│ # fn set_price + service rest /api/products
|
|
||||||
└── ui/
|
|
||||||
└── pricing/ # one screen = one MVC triplet
|
|
||||||
├── pricing.wo # CONTROLLER — route, model: bindings, actions → class methods
|
|
||||||
├── pricing.htmlx # VIEW — plain htmlx (Mustache + wo:live/wo:bind), logic-free
|
|
||||||
└── pricing.scss # VIEW styles — external SCSS, compiled at `wo build`
|
|
||||||
```
|
|
||||||
|
|
||||||
## What runs when
|
|
||||||
|
|
||||||
| File / feature | Goes live in | Plan |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `class` parses; `/api/products` CRUD serves | **13a ✅ shipped** | lexer/parser/AST + spec amendments |
|
|
||||||
| `set_price` / `current_price` over RPC (`POST /api/products/:id/set_price`) | **13b ✅ shipped** | method execution, row-scoped txn, one WAL frame per call |
|
|
||||||
| `@table(name: "prices", index: [product, at])` + indexed DML — `history()` via `select Price{ product == self.id }`, `GET /api/prices?product=1` | **✅ shipped (13 follow-up)** | engine secondary indexes, `find_by`, REST filters |
|
|
||||||
| `subscribe` / `LIVE select` — delta on every commit, WebSocket at `/api/products/live` | **13c** | subscription registry (scoped Stage 3) |
|
|
||||||
| `/pricing` screen patches price cells in open browsers | **13d** | SSR + `wo:live`/`wo:bind` client runtime |
|
|
||||||
| Millions of readers + millions of live recipients | **13e** | scale targets + load harness |
|
|
||||||
|
|
||||||
## The scale story (13e)
|
|
||||||
|
|
||||||
How "one product, millions of customers" works — all of it existing design, instantiated for this demo:
|
|
||||||
|
|
||||||
- **RAM-resident.** The engine is the in-memory design of [`03-inmemory-engine.md`](../../runtime/database/03-inmemory-engine.md); the WAL/disk phases (10–12) sit *behind* the read path for durability, never in front of it.
|
|
||||||
- **Reads scale across cores.** Thread-per-core, shared-nothing shards behind `SO_REUSEPORT` ([`09-concurrency-scaleout.md`](../../plan/09-concurrency-scaleout.md)). A hot product row is owned by one shard but read-replicated to every thread, refreshed by the same per-thread broadcast that feeds subscribers — so `GET /api/products/1` spreads over all cores with zero contention, while writes keep a single owner.
|
|
||||||
- **Live updates fan out in two stages.** One `set_price` commit → one delta message **per thread** (not per subscriber, per plan 09d) → each thread predicate-matches its local subscription table and batches socket writes on its own ring. Kernel primitives only: edge-triggered epoll today ([`done/02-event-loop-epoll.md`](../../plan/done/02-event-loop-epoll.md)), per-thread io_uring next ([`exploration/linux/07-io_uring.md`](../../plan/exploration/linux/07-io_uring.md)).
|
|
||||||
|
|
||||||
Verification targets (1 M aggregate reads/s of one product on 16 cores, 1 M live subscribers with p99 delta delivery < 250 ms, commit→first-delta p99 < 10 ms) are defined in [plan 13 § 13e](../../plan/13-class-model-live-pricing.md).
|
|
||||||
|
|
||||||
## Try it today
|
|
||||||
|
|
||||||
```bash
|
|
||||||
just pricing-demo # scripted CRUD round-trip, self-contained
|
|
||||||
# or:
|
|
||||||
cargo run --bin wo -- run docs/examples/pricing
|
|
||||||
# [wo] compiled catalog — 2 types
|
|
||||||
curl -X POST localhost:8080/api/products \
|
|
||||||
-H "Content-Type: application/json" -d '{"sku":"WO-001","name":"writeonce mug"}'
|
|
||||||
curl localhost:8080/api/products
|
|
||||||
```
|
|
||||||
|
|
||||||
Methods and live push are the next sub-phases (13b/13c). For the `type`-based minimal program, see [`../hello/`](../hello/); for the full workspace shape, [`../../examples/ecommerce/`](../ecommerce/).
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
{{!--
|
|
||||||
pricing.htmlx — the VIEW of the /pricing screen (MVC). Plain htmlx:
|
|
||||||
Mustache + <wo:live>/wo:bind per docs/plan/exploration/ui/01-htmlx-format-spec.md.
|
|
||||||
Logic-free — `products` and `watchlist` come from the controller's model:
|
|
||||||
block (pricing.wo); actions are raised by name, the controller dispatches.
|
|
||||||
|
|
||||||
Live behavior (13c/13d): when set_price commits, only the wo:bind cells of
|
|
||||||
the affected row patch in place — no reload, no row re-render.
|
|
||||||
--}}
|
|
||||||
<section class="pricing">
|
|
||||||
<h1>Live pricing</h1>
|
|
||||||
|
|
||||||
<wo:live source="products" key="id" sort="name asc">
|
|
||||||
<table class="price-table">
|
|
||||||
<thead>
|
|
||||||
<tr><th></th><th>Product</th><th>SKU</th><th>Price</th><th>Updated</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{#each products as p}}
|
|
||||||
<tr data-key="{{p.id}}" class="{{#if (in watchlist p.id)}}watched{{/if}}">
|
|
||||||
<td class="watch-toggle">
|
|
||||||
{{#if (in watchlist p.id)}}
|
|
||||||
<button wo:action="unwatch" wo:args="{{p.id}}">★</button>
|
|
||||||
{{else}}
|
|
||||||
<button wo:action="watch" wo:args="{{p.id}}">☆</button>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td class="name">{{p.name}}</td>
|
|
||||||
<td class="sku">{{p.sku}}</td>
|
|
||||||
<td class="price" wo:bind="prices">{{> money amount=p.current_price}}</td>
|
|
||||||
<td class="updated" wo:bind="prices">{{relative p.prices[-1].at}}</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</wo:live>
|
|
||||||
</section>
|
|
||||||
|
|
@ -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); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
81
justfile
81
justfile
|
|
@ -1,9 +1,5 @@
|
||||||
# writeonce — task runner. `just --list` shows all recipes.
|
# 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
|
# 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),
|
# 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 /
|
# 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}} 5 /
|
||||||
$B $base {{port}} {{conns}} 3 /api/notes '{"title":"bench"}'
|
$B $base {{port}} {{conns}} 3 /api/notes '{"title":"bench"}'
|
||||||
$B $base {{port}} 10000 0 /healthz
|
$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
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue