rindled is the stateful tier — the always-up server you run like Postgres. It
owns the SQLite database and the live IVM pipelines, derives the incremental delta
after every committed write, and streams normalized, cv-stamped updates to every
subscriber. The browser subscribes to its public WebSocket; your
API server drives its private HTTP control plane.
It is the only stateful thing you operate. See the architecture for how the three tiers fit; this page is about running the daemon itself.
The binary
rindled lives in the rindle-server crate. Build it once, then run it against a
JSON config:
cargo build -p rindle-server --bin rindled --release
./target/release/rindled --config daemon.json
The config declares the database, the two ports, an optional auth token, the worker count, and the tables to register at boot:
{
"db": "issues.db",
"httpPort": 7600,
"wsPort": 7601,
"authToken": "dev-daemon-token",
"nWorkers": 4,
"tables": [
{
"name": "issue",
"createSql": "CREATE TABLE IF NOT EXISTS issue (id TEXT, title TEXT, status TEXT, priority TEXT, owner TEXT, tags TEXT, comments TEXT, createdAt REAL, updatedAt REAL, PRIMARY KEY (id))"
}
]
}
db— the file-backed wal2 SQLite database (defaults torindle.db).httpPort/wsPort— the control and subscription ports.0binds an ephemeral port (handy in tests; the chosen port comes back in the readiness signal).authToken— a bearer token required on both planes. Omit it for open local dev.nWorkers— IVM worker threads in the underlyingCluster(defaults to 2).defaultLeaseTtlMs— how long a materialization lease lives without renewal.tables— each entry registers a base table; an optionalcreateSqlruns first (useCREATE TABLE IF NOT EXISTSso restarts are safe), or omit it to register an already-existing table.
On a successful start rindled prints exactly one line of JSON to stdout, so a
supervisor or test runner can wait on it:
{"ready":true,"httpPort":7600,"wsPort":7601}
Two planes
rindled exposes two network surfaces, kept separate so the untrusted browser plane
and the trusted server-to-server plane never share a door:
| Plane | Port | Who connects | Carries |
|---|---|---|---|
| Public WebSocket | wsPort |
browser clients | the normalized protocol — init, subscribe / unsubscribe, and cv-stamped snapshot + delta frames out |
| Private HTTP control | httpPort |
your API server | /materialize (mint a query lease), /execute-sql-txn (apply a write with idempotency), /reject-mutation, /dematerialize |
Both planes require the bearer authToken when one is set. Clients never speak the
control plane directly — they go through your API tier, which
holds the query registry and the authoritative mutators. The
@rindle/daemon-client package is the
typed client for the HTTP plane.
Restart recovery: the boot id
rindled keeps no durable materialization state — on restart it has the data
(it’s file-backed) but no live queries or pins. So it stamps every control-plane
response with a boot id header that changes when it restarts. The
HttpRindleDaemonClient surfaces it via onBootId, and your API server re-asserts
its pinned queries when it fires:
const daemon = new HttpRindleDaemonClient({
baseUrl: "http://127.0.0.1:7600",
headers: { authorization: `Bearer ${token}` },
onBootId: () => api.assertPins().catch(console.error), // re-warm after a restart
});
The hook rides responses you already make, so there’s no polling — the next control-plane call after a restart re-establishes the warm set.
Under the hood: the Cluster
rindled runs the multi-threaded Cluster engine from rindle-replica. Where
the single-thread Db advances every query on one
thread, Cluster shards queries across a pool of IVM worker threads behind a single
writer/coordinator. The per-transaction handshake keeps the parallelism correct:
writeopens aBEGIN CONCURRENTon the writer; the preupdate hook captures the row deltas as SQL runs.commitfans the captured batch to every worker and waits for each to pin its own pre-commit snapshot (the barrier).- The writer commits durably while the workers derive their queries’ deltas concurrently under snapshot isolation.
- Each worker emits its affected queries’ deltas, then a progress marker so the drain layer knows the transaction is fully delivered.
A query lives on exactly one worker, so per-query event order is preserved. If a
worker faults during derivation, that query is torn down (and the pool respawns the
worker) rather than corrupting the stream. The contract is unchanged from the
single-thread path: view-after-write == fresh-query. See
crates for the Cluster API.
The query planner
The daemon can opt into the cost-based join-flip
planner at open time — it annotates each
flippable EXISTS with a flip decision before lowering, picking the cheaper drive
side from a real-SQLite cost model. It is result-preserving (only the work
changes, never the rows) and is off by default; today it is enabled in-process
via Cluster::open_with_planning, not yet through the config file.
Scope
rindled is the productionizing server, but it is young. The replica’s schema
constraints apply — plain tables (no triggers / generated columns), numbers within
±(2⁵³−1); see replica and views. The bearer token is the
only auth primitive; finer-grained authz lives in your API tier.
Horizontal scale-out (multiple daemons over one durable change log) is designed but
not built here.
Next steps
- The API server — the tier that drives the control plane.
- The browser client — what subscribes to the ws plane.
- Full app: the issue tracker —
rindledbooted and wired to both other tiers with one command. - Crates & API map —
rindle-replica(Db/Cluster),rindle-server, andrindle-planner.