High-concurrency runtime

@rindle/cli

The npm-installed Rindle toolchain: local fleet and app lifecycle, SQL, local HCTree/wal2 inspection, migrations, schema generation, backup, and Rindle Cloud deployment.

View as Markdown

@rindle/cli is the JS/TS developer toolchain for Rindle. It installs the rindle CLI together with matching rindle-replicator, rindled, and rindle-dev-edge binaries as prebuilt, per-platform npm artifacts — no Rust toolchain required for app development.

Use it to:

  • scaffold a local topology with rindle init
  • own the complete local fleet + app lifecycle with rindle dev
  • supervise only the local fleet with rindle up
  • query or modify a database with rindle sql
  • inspect local HCTree master and wal2 follower files with rindle db
  • apply pure-DDL and pure-DML migrations with rindle migrate
  • generate @rindle/client TypeScript schema with rindle schema gen
  • deploy, link, and migrate Rindle Cloud apps

Install

pnpm add -D @rindle/cli
# or npm i -D @rindle/cli

Run it through the package manager’s bin resolution:

npx rindle --help
pnpm exec rindle status

The package selects the platform artifacts it needs through optional dependencies. All four binaries are versioned and installed together, so rindle dev can always find the master, follower, and edge it was released with.

Supported platforms

Prebuilt binaries ship for macOS (Apple Silicon + Intel) and Linux (x86_64 + arm64, glibc + musl).

Windows: use WSL2. There is no native Windows build — Rindle’s storage engine is Linux-only. Installing on Windows fails fast with a message pointing here rather than silently half-working. WSL2 runs a real Linux kernel, so the normal Linux binaries and the whole rindle dev loop work unmodified inside your distro. Your app and browser stay on Windows: WSL2 forwards localhost, so a dev server started in the distro is reachable from a Windows browser. The browser tier is wasm and doesn’t care what OS it runs on.

One rule that matters:

Keep the database on the WSL filesystem (~/), never under /mnt/c. Windows drives are exposed to WSL through a translation layer that can’t reliably back a memory-mapped database — the same reason not to put SQLite on a network share. Your source tree can sit wherever you like. The data directory can’t. It’ll also be dramatically faster on the Linux side.

WSL1 is not supported: it emulates the memory-mapping and file-locking calls the engine depends on, rather than implementing them.

Know where a command runs

The CLI separates project files, local processes, app connections, and Rindle Cloud. “Remote” is not a mode: --url means a direct app ingress and can point to localhost, a self-hosted server, or a managed app’s Connect URL. --cloud means the authenticated Rindle Cloud control-plane proxy.

Command Scope App target selectors
context explains available targets; contacts none none
init, render project files none
migrate list, migrate create local migration files none
indices suggest local shapes file; optionally compares live indexes --local or --url; no target is valid
up, dev, exec, ps, stop local fleet/process lifecycle none
db local HCTree/wal2 database file none; choose a role, component, or path
version, status, stats, health, schema running app --local or --url
sql, analyze query, restart, dematerialize, re-bootstrap running app --local or --url
migrate apply, migrate status running app --local, --url, or --cloud
login, logout, whoami, deploy, link Rindle Cloud control plane none; these are always Cloud commands
backup … backup store or downloaded object none

For a running-app command, choose exactly one target:

rindle status --local                    # this project's rendered/authored topology
rindle status --url https://example.test # one directly reachable ingress
rindle migrate status --cloud            # app bound in .rindle/cloud.json

With no selector, RINDLE_URL wins. Otherwise the CLI discovers rindle.json or rindle.ncl in the project. If neither exists, it fails with a target-selection error. There is deliberately no implicit localhost:7600. --local forces topology discovery even when RINDLE_URL is set.

Run rindle context to see the direct connection environment, local read/write endpoints, Cloud binding, and default selection without contacting any of them. rindle context --json is suitable for scripts. --remote remains a deprecated alias for --cloud during migration.

Local dev loop

Most apps use one command for the complete local lifecycle:

npx rindle init
npx rindle dev --migrate --gen shared/schema.gen.ts -- vite dev

rindle dev evaluates rindle.ncl once, starts the write-master + follower + local fleet edge, waits for every public read component, applies pending migrations/*.sql, waits for the follower to replicate them, generates shared/schema.gen.ts, and only then starts the app. It injects the unified RINDLE_URL + RINDLE_DATABASE_TOKEN connection, watches requested migration/schema inputs, forwards signals, and tears down the app and fleet together.

Use rindle up when you deliberately want only the fleet. rindle exec remains a compatibility adapter for running a one-shot command with topology-derived bindings, but normal development no longer needs either a second supervisor or a readiness script.

Running several projects at once

Local ports are allocated per project, not fixed. Each project gets a 100-wide block, chosen from the path of the directory holding rindle.ncl and remembered in ~/.rindle/ports.json. So two Rindle projects — or two git worktrees of one project — can run at the same time without contending for a port. Within the block:

Component Port
follower i (private rindled) portBase + i*2 (control), portBase + 1 + i*2 (ws)
replicator (write-master) portBase + 11 (control), portBase + 10 (fan-out ws)
fleet edge (the app-facing URL) portBase + 50

You don’t need these numbers: rindle dev injects RINDLE_URL, rindle.json’s bindings carries the resolved URLs, and rindle render / rindle up print them. To pin a block instead, set portBase in rindle.nclportBase = 7600 reproduces the fixed ports used before allocation.

Each project also carries a fingerprint of its root path, which its daemons advertise on GET /version and the CLI asserts on every control-plane call. If a command reaches a daemon from a different project, it is refused with a 409 naming both — rather than, say, applying one app’s migrations to another app’s database. This is why the fingerprint is derived from the path and not from the app name: two worktrees of one repo share a name and differ only by where they live.

$ rindle migrate apply
daemon error 409: wrong project: this rindle-replicator write-master serves 'my-app'
(3b90321256fd), but the command came from a DIFFERENT checkout of 'my-app' (3b90621256fd).
Refusing: applying it would have written one project's migrations into the other's database.

Starting a fleet whose ports are already taken fails immediately and names the holder, instead of retrying in the background:

$ rindle up
  ✖ cannot start 'my-app' — 5 of its port(s) are already in use
    127.0.0.1:20911  (replicator)  held by rindle app 'other-app' (7b20e8449c31)
  Another Rindle fleet ('other-app') is running and owns these ports.

The replication connection is fenced the same way: a follower stamps its fingerprint on the subscribe it sends to the write-master, and a master serving a different project refuses it before sending a single frame. Without that, a follower whose own master lost the port race replicates the other project’s entire journal into its database. It then serves those rows as its own. The refusal is terminal, not a retry — the follower halts that source and says so, since reconnecting only re-asks the same wrong master.

Peers that send no identity — a browser client, @rindle/sql-client, curl, a BYO relay consumer — are never fenced. Neither are deployed daemons, which carry no project identity at all. To drive another project’s daemon on purpose, set RINDLE_PROJECT_ID to its fingerprint (or to the empty string to assert nothing).

Upgrading from the fixed ports

Before this, every project rendered the same loopback ports: the follower on :7600/:7601, the write-master on :7610/:7611, the fleet edge on :7650. Your fleet moves to its own block on the next rindle up, so anything that hardcoded one of those numbers stops working. Two ways forward:

  • Recommended — stop hardcoding. rindle dev injects the resolved URLs as environment (RINDLE_URL, RINDLE_DAEMON_URL, RINDLE_REPLICATOR_URL, …) and rindle.json’s bindings carries them for anything that reads the manifest directly. Prefer a hard failure over a default: a literal ?? "http://127.0.0.1:7600" no longer points at nothing, it points at whatever other project holds that port. An app-tier client sends no project identity, so nothing stops it reading the wrong database.
  • Pin the old block. portBase = 7600 in rindle.ncl reproduces the previous ports exactly. Fine for a single-project machine. It gives up the “two checkouts at once” property.

Nothing about a deployed fleet changes: production renders carry no project identity and no allocation, and rindle deploy is unaffected.

Inspect local database files

Stock sqlite3 cannot open Rindle’s HCTree master or wal2 follower files. rindle db uses the same Bedrock SQLite build as the runtime and opens the selected file physically read-only:

# Resolve these roles through rindle.json and each component's generated config:
npx rindle db master
npx rindle db follower
npx rindle db follower-0 "SELECT * FROM issue LIMIT 10"

# An explicit path is also accepted; --json makes one-shot output machine-readable:
npx rindle db ./data/follower.db --json "SELECT * FROM _rindle_source_offsets"

With no statement and a terminal on stdin, the command opens an interactive shell. It supports .tables, .schema [name], .indexes [table], .mode table|json, and .quit. SQL can instead come from --file or piped stdin. Use --manifest <path> when the rendered manifest is not ./rindle.json. If a fleet has multiple followers, use the component name rather than the ambiguous follower alias.

The command opens with SQLITE_OPEN_READ_ONLY, forbids ATTACH, and rejects any statement SQLite marks writable. It never changes journal mode or checkpoints the source. A stopped wal2 database whose shared-memory sidecar has already disappeared can refuse a strictly read-only connection. Start its follower or inspect a restored portable base instead. Use rindle sql for authoritative application reads and writes over the live ingress. rindle db is the local operator/debugging surface.

Migrations and schema generation

Under rindle dev, migrations apply to the write-master and the schema regenerates from a follower automatically. Local one-shot commands discover those roles from rindle.ncl, so they need neither hard-coded ports nor a nested rindle exec:

npx rindle migrate create init
npx rindle migrate apply
npx rindle migrate status
npx rindle schema gen --out shared/schema.gen.ts

Each migration file is classified as pure DDL or pure DML. Mixed files fail before any request. Destructive DDL (DROP TABLE, DROP COLUMN, and DROP INDEX) is accepted with a [destructive] notice. The reviewed file is the consent. DML is evaluated once on the master and followers receive captured row deltas, not the SQL text. migrate list and migrate status show and verify each file’s kind and checksum.

For Rindle Cloud, deploy or link first records a non-secret app binding, then --cloud sends migrations through the authenticated Cloud proxy:

npx rindle login
npx rindle deploy --migrate

# Or bind an SQL app created in the dashboard:
npx rindle link app_…
npx rindle migrate status --cloud
npx rindle migrate apply --cloud

For a directly reachable self-hosted ingress, use the same application connection as your server:

npx rindle schema gen \
  --url "$RINDLE_URL" \
  --token "$RINDLE_DATABASE_TOKEN" \
  --out shared/schema.gen.ts

RINDLE_DATABASE_TOKEN is server/operator-only. Never ship it to the browser. Browser clients call your API server, and authorized query leases return a public WebSocket endpoint plus a placement ticket.

rindle dev vs. rindle up vs. rindled

rindled is the read-follower daemon. The rindle-replicator write-master feeds it. rindle up is the local-development supervisor that renders your rindle.ncl, starts the pair, watches it, applies migrations to the master, and generates schema from the follower. rindle dev adds readiness gates, the application process, unified bindings, and one signal/teardown boundary.

For application development, prefer rindle dev. For production, run the pair under your real process supervisor or use Rindle Cloud:

  • local dev: npx rindle dev --migrate --gen shared/schema.gen.ts -- vite dev
  • self-hosted production: run the master, followers, and edge from release artifacts, container images, or your own supervisor, then use the unified ingress
  • managed production: rindle deploy a Sync topology or rindle link a dashboard-created SQL app, then use migrate --cloud.

Command reference

Running-app commands use the shared target selection described above. Aliases are in parentheses.

Command Scope What it does
context (ctx) explain only show local, direct, and Cloud targets plus the default
status · stats running app up/down + commit + boot id + the live counters; repaint with --watch
version running app liveness + deployed commit (no token needed)
health / ready running app liveness (+ auth) probe with a meaningful exit code
schema [show] running app the deployed base-table shape
schema gen (generate) running app emit the @rindle/client schema TS — --out <file> (default stdout), --import-from <module>, --schema-const <name>
migrate apply (up) running app apply ordered pure-DDL or pure-DML *.sql; --cloud selects the bound Cloud app
migrate list (ls) project files list local migrations with kind and checksum
migrate create <name> (new) project files scaffold a new migration file
migrate status running app verify local kind/checksum against the applied journals; accepts --cloud
sql [<statement>] running app run SQL from an argument, --file, or stdin; multiple statements form one atomic batch
db <target> [<statement>] local DB file inspect an HCTree master or wal2 follower read-only; target a role, component, or path
init · render [rindle.ncl] project files scaffold or render the local topology
dev -- <command> · up · exec -- <command> local runtime own/supervise the local fleet or run with topology-derived bindings
ps · stop (kill) local runtime list / stop running Rindle processes (stop --all, or by pid)
restart · dematerialize · re-bootstrap running app lifecycle, cleanup, and recovery operations
login · logout · whoami Cloud control Rindle Cloud auth (browser device flow; $RINDLE_CLOUD_TOKEN overrides for CI)
deploy · link <app-id> Cloud control provision or bind an app and write .rindle/cloud.json

rindle --help prints the full list, including the fleet/topology and recovery commands (render, dematerialize, re-bootstrap).

Flags & environment

App-target precedence is explicit selector → RINDLE_URL → local topology → error.

Flag Env var Default Meaning
--local off force this project’s rindle.json/rindle.ncl target, ignoring RINDLE_URL
--url <url> RINDLE_URL local topology, then error connect directly to one Rindle ingress. There is no fixed-port default: ports are allocated per project, so a local fleet is reached through its derived URL
--token <bearer> RINDLE_DATABASE_TOKEN (none) unified server/operator bearer; legacy RINDLE_TOKEN / RINDLE_DAEMON_TOKEN remain fallbacks
--cloud off (migrate apply/status) use the bound app through Rindle Cloud
--remote off deprecated alias for --cloud
--topology <file> rindle.json, then discovered rindle.ncl explicit local topology for a running-app command
--cloud-url <url> RINDLE_CLOUD_URL https://cloud.rindle.sh Rindle Cloud control plane
--dir <path> RINDLE_MIGRATIONS_DIR migrations the migrations directory (migrate *)
--out <file> stdout where schema gen writes
--gen <out> (dev/up) regenerate the schema TS to <out> after --migratealways takes the path
--json off machine-readable output
--watch, -w off repaint on an interval (status/stats); under up, re-apply/regen on change (dev watches requested inputs automatically)
--interval <s> 2 the --watch repaint interval
--timeout <s> 5 per-request timeout
--manifest <file> rindle.json (db) rendered fleet manifest used to resolve master/follower component paths
--daemon-bin <p> RINDLE_DAEMON_BIN sibling of rindle, then $PATH (dev/up) explicit path to the rindled binary
RINDLE_REPLICATOR_BIN sibling of rindle, then $PATH (dev/up) explicit path to the write-master binary
RINDLE_DEV_EDGE_BIN sibling of rindle, then $PATH (dev/up) explicit path to the native local fleet-edge binary
RINDLE_BIN_DIR the platform npm package (npm wrapper) a directory of locally built binaries to use instead of the prebuilt ones

Supervise from Node

If you need to supervise the npm-installed daemon from Node, import the package helpers:

import { rindledBinaryPath, spawnRindled } from "@rindle/cli";

const child = spawnRindled(["--config", "./follower.json"]);
console.log(rindledBinaryPath());

Package scripts

If you want a package script, make it forward to the CLI:

{
  "scripts": {
    "rindle": "rindle",
    "dev": "rindle dev --migrate --gen shared/schema.gen.ts -- vite dev"
  }
}

Then run:

npm run dev
# or
npm run rindle -- status

Next steps