rindled is the read-serving tier of the Rindle data tier — the
always-up server you run like Postgres. It is a read-follower: it holds a SQLite
replica of the data and the live IVM pipelines, derives the incremental delta after
every write it receives from the rindle-replicator write-master, and
streams normalized, cv-stamped updates to every subscriber. Writes never enter a
follower — they go to the write-master; a follower has no write ingress at all. The
browser subscribes to a follower’s public WebSocket; your
API server reads and materializes over its private HTTP control
plane (and sends writes to the write-master).
See the architecture for how the three tiers fit and deploying & scaling for the write-master that feeds it; this page is about running the follower daemon itself.
The binary
rindled lives in the rindle-server crate and ships as a prebuilt, per-platform
binary with @rindle/cli. For local dev you rarely invoke it
directly — rindle up renders your rindle.ncl and supervises
the whole pair (the rindle-replicator write-master and its follower) for you.
It’s the first thing the synced-app quickstart does.
This page is the follower daemon in full — the config, the two planes, restart
recovery, and the engine underneath — for when you run it yourself.
To run a follower directly (in production, or under your own supervisor), point a
release, container, or otherwise supervised rindled binary at a JSON config:
rindled --config follower.json
# …or, with a Rindle source license (rindle-server is a commercial crate):
cargo build -p rindle-server --bin rindled --release
./target/release/rindled --config follower.json
The config declares the follower’s replica file, the two ports, an optional auth token, the worker count, and the change source it tails — the write-master’s fan-out stream:
{
"db": "follower.db",
"httpPort": 7600,
"wsPort": 7601,
"authToken": "dev-daemon-token",
"nWorkers": 4,
"sources": [
{ "kind": "replicator", "name": "rindle-master", "url": "ws://127.0.0.1:7610/subscribe" }
]
}
db— the follower’s file-backed wal2 SQLite replica (defaults torindle.db); the write-master owns the authoritative copy.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.sources— exactly onekind:"replicator"change source: the write-master’s fan-out WebSocket (ws://…:7610/subscribe). The follower dials out, tails the totally-ordered change log, and applies it. The schema arrives the same way — DDL replicates from the master — so there’s no table list here: new tables are auto-discovered as the master’s migrations flow through, andrindle schema genregenerates the client schema from the follower’s/schema.
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
A follower 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 | reads only — /materialize (mint a query lease), /execute-sql-read (a raw read), /dematerialize, /schema, /version |
The write endpoints — /execute-sql-txn, /migrate, /mutate-session/*,
/reject-mutation, /apply-row-change-txn — live on the
rindle-replicator write-master, not on a follower. Point writes
at a follower and it refuses them with a fail-closed error that names the master, so a
misrouted write can’t silently vanish.
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 and routes reads to the follower and
writes to the write-master (its SplitDaemonClient
does the split). 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. On a follower the transactions it applies arrive over the
replication stream from the write-master rather than from a client, but the engine
mechanics are identical. 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 runs the cost-based join-flip
planner — 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 on by default (Cluster::open enables it); opting out is
in-process only today, via Cluster::open_with_planning(path, n, false), 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.
This page runs one follower. To scale reads across N affinity-placed followers fed by the write-master, see deploying & scaling, which lays out the whole deployment menu and what you can run yourself versus have us run for you on Rindle Cloud.
Next steps
- The API server — the tier that drives the control plane.
- The browser client — what subscribes to the ws plane.
@rindle/cli— the local supervisor, migration, and schema-gen toolchain that ships the daemon binary for JS/TS projects.- 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. - Deploying & scaling — from this one node to a read-scaled fleet, and what runs it for you on Rindle Cloud.
- Connect your app to Rindle Cloud — the managed counterpart of this page’s two planes: the endpoints and token, surfaced on your dashboard.