15 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this repo is
writeonce is a declarative full-stack programming language. You write .wo files; the runtime compiles them into a single binary that owns the database, serves REST, and (Stage 3+) pushes live subscriptions. Think: Go + Postgres + net/http + Phoenix LiveView folded into one language.
The end goal — zero external deps, kernel primitives only
The runtime's north star — documented in docs/01-problem.md, docs/02-recovery.md, and docs/plan/linux/00-linux.md — is one binary, no external Rust crates, all I/O driven directly by Linux kernel primitives. epoll (or io_uring), inotify, eventfd, timerfd, signalfd, sendfile, mmap — the kernel IS the subscription engine, the async runtime, and the file watcher.
Target end state of crates/rt/Cargo.toml:
[dependencies]
libc = "0.2" # the unavoidable FFI bridge to syscalls
Stage 2 today carries six transitional deps (anyhow, serde, serde_json, tokio, axum, tower). docs/plan/02 through docs/plan/08 sequence the removal of each one, replaced by hand-rolled modules ported from the v1 crates that already did exactly this (reference/crates/wo-event, wo-http, wo-route, wo-serve, wo-watch). When working on the runtime, default to direct-syscall solutions over reaching for new crates — the docs/plan/ docs name the port source for every module.
Layout
The repo holds three cuts of the same project plus one research reference:
crates/rt/— the active Rust runtime (Stage 2 shipped). Monolithic on purpose for now: lexer, parser, AST, in-memory engine, axum REST server all in one crate. Thewobinary lives atcrates/rt/src/bin/wo.rs.crates/{ql,value,engine,txn,db,wal,sub,http,gen,policy,logic,service,ui,app}/— 14 empty sibling crates scaffolded to match the 7-phase design. Each has aCargo.toml+src/lib.rswith just a doc comment pointing at its phase spec. Real code moves in fromrtas each phase activates; do NOT refactorrtto use these today — it would break Stage 2.reference/crates/— the v1 writeonce blog (13 crates:wo-seg,wo-index,wo-store,wo-htmlx, etc.). This is a nested Cargo workspace, deliberately excluded from the root workspace. The v1 crates keep theirwo-prefix; the new runtime crates dropped theirs.cd reference/crates && cargo buildbuilds v1 standalone. Seedocs/runtime/database/07-wo-seg-migration.mdfor the plan replacing v1 with the new runtime.reference/linux/andreference/go/— symlinks to the Linux kernel source tree and the Go source tree respectively. Not committed (see.gitignore). Research resources:reference/linux/— grepio_uring/,fs/notify/inotify/,kernel/eventfd.c,include/uapi/linux/*.hwhen designing the kernel-primitive modules. Paired with per-primitive reference cards atdocs/plan/linux/.reference/go/— grepsrc/runtime/netpoll_epoll.go,netpoll.go,os_linux*.go,asm_*.s,sys_linux_*.swhen designing the runtime layer. Thecrates/rt/src/runtime/module mirrors Go'ssrc/runtime/file-per-flavour naming (netpoll_epoll.rs↔netpoll_epoll.go). Thedocs/plan/assembly/docs cite this tree.- Each contributor sets their own targets via
ln -s <path-to-src> reference/{linux,go}.
There is also prototypes/wo-db/ — a ~2k-line C++ prototype of the query-layer engine (SQL + Cypher + document paths, RETURNING aliases, LIVE stub). It keeps its wo-db directory name (C++ project, separate from the Rust crate db). It's the reference implementation the Rust port follows; make test still passes.
And prototypes/wo-rt-c/ — a single-file C reference of the runtime layer (edge-triggered epoll loop, signalfd shutdown, non-blocking listener, in-RAM store over minimal HTTP; libc only). It's the runtime-layer sibling of wo-db: each block maps one-to-one to a crates/rt/src/runtime/ module (table in its README). make builds it; just rt-c-demo exercises it. Its evolution into a multi-threaded io_uring RAM-database runtime (thread-per-core, mmap arena, WAL dual-write, recovery, ACID) is phased A–F in docs/plan/exploration/c-runtime/00-plan.md, with the one-address architecture trace beside it (01-architecture.md) — the proving ground for plans 09–12. Documentation belongs under docs/ — prototype/crate directories keep only their orientation README.
Commands
# Build + test the runtime
cargo build # compiles all 15 crates
cargo test --lib # 14 unit tests (all in rt today)
cargo test --lib parses_inline_struct -- --nocapture # single named test
# Run the runtime against a sample project
cargo run --bin wo -- run docs/examples/blog # :8080 — blog sample
cargo run --bin wo -- run docs/examples/ecommerce # :8080 — ecommerce sample
WO_LISTEN=127.0.0.1:9000 cargo run --bin wo -- run docs/examples/blog # override port
# v1 blog codebase (nested workspace — must cd first)
cd reference/crates && cargo build && cargo test
# C++ prototype of the query-layer engine
cd prototypes/wo-db && make test # smoke.wo + checkout.wo
cd prototypes/wo-db && make run # interactive REPL
# Manual HTTP smoke against a running `wo run ...`
# Open reference/rest/blog.rest or ecommerce.rest in VS Code (with REST Client)
# or JetBrains (built-in HTTP client). Or run curl per reference/rest/README.md.
Architecture — what requires reading multiple files to understand
Naming convention
- New runtime crates are unprefixed.
ql,value,engine,txn,db,wal,sub,http,gen,policy,logic,service,ui,app,rt. Internal imports read cleanly:use ql::Parser,use db::Tx,use http::router. - V1 crates keep the
wo-prefix.wo-seg,wo-index,wo-store,wo-htmlx,wo-md, and v1's ownwo-rt/wo-http/wo-sub. These live inreference/crates/. - The C++ prototype directory is
prototypes/wo-db/— unchanged, not a Rust crate. - The binary is
wo— defined incrates/rt/Cargo.toml[[bin]]. Independent of the crate name.
The two-layer .wo language
Covered in docs/runtime/database/02-wo-language.md:
- Schema layer — unified
type Name { ... }DSL (fields, embedded structs,ref,multi @edge,multi via,backlink, tagged unions,policy,on <event>,service). This is what developers write day-to-day. - Query layer — hybrid SQL + Cypher with five "fixed-glue" rules that make the three grammars share semantics:
$nameparameters everywhere, cross-paradigmRETURNING col AS aliasvisible to later statements in the sameBEGIN … COMMIT, dotted paths identical in SQL/doc/Cypher, one transaction block syntax, oneLIVEprefix on subscriptions.
Both layers are .wo files. The schema layer compiles down to query-layer operations — but only when Phase 5 codegen and Phase 6 full-stack blocks need a single authoritative input. Stage 2 ships with the schema layer only.
Concurrency: thread-per-core event loops (09a shipped)
Since plans 09a+09b, wo run boots WO_THREADS pinned worker threads (default = online cores; wo-shard-<t> in ps -T), each running its own epoll EventLoop with its own SO_REUSEPORT listener (crates/rt/src/runtime/scheduler.rs) and its own Engine — there is no Arc<Mutex<Engine>> anywhere. Ids interleave per shard (Engine::for_shard; owner = (id-1) % n); cross-shard operations travel the shard bus (crates/rt/src/shard.rs: mpsc job mailboxes + mail eventfds; creates local, point ops hop once, lists fan out and merge). Deadlock-freedom: jobs never block, waiters pump their own inbox. Routers are thread-local (HandlerFn is not Send/Sync). Signals are blocked before spawn; worker 0 owns the signalfd and broadcasts shutdown via per-worker eventfds. Don't reintroduce shared mutable engine state — the doctrine below is now enforced by ownership.
Single-threaded event loop (original doctrine)
Covered in docs/runtime/database/02-wo-language.md § Concurrency Model and 03-inmemory-engine.md. The runtime is Redis/TigerBeetle-style: one userland thread owns everything — connection accept, parser, engine, subscription registry. The only non-userland thread is the kernel-owned io_uring SQPOLL helper. This is pinned architecturally — group commit still applies (loop drains many commits into one fsync SQE per tick), and scaling past one core is done by sharding independent engine processes, not by adding worker threads. Keep this in mind before proposing Arc<Mutex<...>> anything beyond what's already there.
What's in rt today vs. what the empty crates promise
rt's modules deliberately mirror the future crate names so the extraction is mechanical when each phase activates:
rt module |
Will move to | Phase |
|---|---|---|
token.rs + lexer.rs + ast.rs + parser.rs |
ql |
2 |
engine.rs (Value + Row helpers) |
value |
2 |
engine.rs (Engine + Catalog) |
engine |
2 |
compile.rs |
engine |
2 |
method.rs (13b method executor) |
logic |
6 |
server.rs |
http + service |
4 / 6 |
bin/wo.rs |
stays in rt (the binary) |
— |
The sub, wal, txn, policy, logic, ui, app, gen crates have no rt counterpart yet — they land when their phase activates.
Sample projects drive the grammar
docs/examples/blog/ and docs/examples/ecommerce/ are both docs artifacts and the de facto integration tests. The parser survives these because specific features in them (nested {...} object literals inside trigger actions, count(...) / words(...) computed defaults, unions like Pending | Paid | Shipped) forced real fixes. When changing the parser, run the full end-to-end against both samples, not just cargo test.
The ecommerce sample in particular uses features that are deliberately parse-and-discard in Stage 2: fn checkout(...) in txn snapshot, type-attached on update triggers with multi-line do actions, policy read for role .... These are part of the .wo language but Stage 2 does not execute them.
The migration story
docs/runtime/database/07-wo-seg-migration.md specifies phased coexistence: abstract the v1 article store behind an ArticleStore trait, stand up the .wo engine as a second implementation, dual-write, cut over, decommission v1. Phase A (trait abstraction) hasn't started — the plan is on paper, the v1 code is still monolithic in reference/crates/wo-store/. Do not remove anything from reference/crates/ without checking the migration doc.
Stage progress
| Stage | Status |
|---|---|
1 — wo run <dir> discovers .wo files |
✅ shipped |
| 2 — parser + engine + REST CRUD | ✅ shipped (cargo run -- run docs/examples/blog) |
| 3 — LIVE subscriptions over WebSocket | pending — /api/<type>/live returns 501 as a stub |
4+ — transactional fn, policies, triggers, ##ui, WAL, codegen |
design-only (see docs/runtime/database.md) |
Stage-3 stubs (501) and policy-shaped 405/404 responses are intentional and documented in reference/rest/*.rest. Don't "fix" them without checking those files first.
Non-obvious gotchas
rtis monolithic on purpose. Splitting it into the 14 sibling crates is Phase-by-Phase work, not a Stage-2 refactor.reference/crates/is its own workspace. Runningcargo buildat the root does not build v1. Running it inreference/crates/does.- Parser identifiers vs. keywords.
subscribe,receive,expect_abort,me,self, and lowercaseinsert/selectare NOT keywords in the lexer — they stay as plain idents (only SQL-layerINSERT/SELECTare keywords). The 13b statement parser matchesinsertas an ident; the select expression is recognised by the two-token shapeselect <Ident> {. Adding these to the keyword map breaksservice rest "..." expose subscribeand method bodies. - Type-level annotations.
@table(name: "...", index: [a, b])before atype/classconfigures storage (it never toggles table-ness — every type IS a table). Unknown keys inside@table(...)are parse errors; unknown annotation names (@foo) skip silently. Engine secondary indexes are maintained ONLY viaEngine::row_insert/row_remove— never touchtablesdirectly or indexes drift. - Parser skip-on-block. Unknown triggers (
on update do ...) are parsed-and-discarded by brace-depth-aware skipping. Object literals like{ article_id: self.id }inside trigger actions contain}that must not be mistaken for the type's outer close brace — the depth counter exists specifically because of this. Exception since 13b:fninside aclassparses into a realMethodDecl(body statements + expressions, executed bymethod.rs);fninside a plaintypestill skips. - Newline significance. The lexer emits
Kind::Newlinetokens and the parser uses them to end policy/trigger lines. Do not filter newlines globally. - Default-value parsing.
= now()is recognised explicitly asDefaultExpr::Now; anything else falls into an opaque-expression path thatengine::eval_defaultthen omits from created rows (computed fields display as empty, not as debug-printed tokens). - Binary variable shadowing.
crates/rt/src/bin/wo.rshaslet rt = ...(a tokio runtime handle) insiderun()that shadows the crate namedrt. Insiderun()the variable wins; insideserve()(a different function)rt::refers to the crate. Don't rename the variable without also auditing the crate-path references.
Where to read next
docs/runtime/wo-language.md— user-facing language overviewdocs/runtime/database.md— 7-phase engineering series indexdocs/plan/linux/00-linux.md— catalogue of kernel primitives the runtime leans ondocs/plan/02-event-loop-epoll.mdthrough08-sendfile-static-assets.md— the dependency-removal phase sequencedocs/plan/done/01-scafolding-crates.md— the completed crate-scaffolding phasedocs/examples/blog/README.md— the canonical worked exampleprototypes/wo-db/README.md— the C++ prototype that shows the query layerreference/rest/README.md— how to exercise the running prototypereference/README.md— what's in the v1 archive and why it's preservedreference/linux/(symlink) — the Linux kernel source tree; grepio_uring/,fs/notify/inotify/,include/uapi/linux/*.hwhen designing kernel-facing modulesreference/go/(symlink) — the Go source tree; grepsrc/runtime/netpoll_*.go,asm_*.s,sys_linux_*.swhen porting runtime-layer ideas (writeonce'scrates/rt/src/runtime/mirrors this naming)docs/plan/assembly/00-overview.md— role of assembly in a runtime; writeonce policy is "no custom asm, use Rust stdlib"crates/README.md— inventory of all 15 crates with phase assignments