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