Build a synced app

Run the daemon (rindled)

rindled — the always-on Rindle server you run like Postgres. The JSON config, the two network planes, restart recovery via the boot id, and the multi-threaded Cluster engine underneath.

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 to rindle.db).
  • httpPort / wsPort — the control and subscription ports. 0 binds 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 underlying Cluster (defaults to 2).
  • defaultLeaseTtlMs — how long a materialization lease lives without renewal.
  • tables — each entry registers a base table; an optional createSql runs first (use CREATE TABLE IF NOT EXISTS so 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:

  1. write opens a BEGIN CONCURRENT on the writer; the preupdate hook captures the row deltas as SQL runs.
  2. commit fans the captured batch to every worker and waits for each to pin its own pre-commit snapshot (the barrier).
  3. The writer commits durably while the workers derive their queries’ deltas concurrently under snapshot isolation.
  4. 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