Most Rindle bugs are contract violations, not framework bugs. The contract is small. This page states it once, then works through the failure modes you’ll actually see, most common first.
The rules that keep an app correct
These are not style preferences. Break one and the app is wrong in a way tests don’t always catch immediately.
- SQL is the source of truth. The TS schema is generated. Evolve the schema by
adding an ordered migration (pure DDL for schema, pure DML for data), and never
hand-edit
schema.gen.ts— see Schema & migrations. - A mutator is one isomorphic body, run on both tiers. Write it once with
shared(args, gen). Add a hand-written server entry only for authority the client must not predict — see Isomorphic mutators. - Mutators must be deterministic and replayable. No
Date.now(), noMath.random(), no I/O — ids and timestamps arrive as args, and the acting user isctx.user, never a client-supplied arg. - Only
(name, args)crosses the wire. Client-built ASTs and client-computed effects never become authority. The server parses untrusted args through the mutator’s.argsschema and each query’s validator. - Remote subscriptions must be named. Only a
defineQueryvalue opens a server subscription. A bare builder query resolves locally. - Subscribe to windows, not whole tables. Order +
limit, and raise the limit for “load more”. - The database token is server-only. Only trusted code receives
RINDLE_URL+RINDLE_DATABASE_TOKEN. The browser receives an authorized lease, public WebSocket endpoint, and placement ticket. - Keep
*.queries.tsmodules framework-free. The browser, the API authority, and any SSR loader all import them. No component imports.
Everything below is one of these rules, broken.
Nothing syncs / a query never leaves “loading”
- The query isn’t named. Only a
defineQueryvalue opens a server subscription. A barestore.query.<table>.where…builder resolves locally only — it renders off already-synced rows and never pulls new data. Wrap it indefineQuery, callmyQuery(args), and register it on the server. - The query isn’t registered on the server. Add it to the
registerQueries<User>([...])list in your API server. An unregistered name can’t resolve to an AST. - The lease has no usable
wsEndpoint. Remove old browser-sidedaemon.wsUrlconfiguration. Configure the API server withrindle: { url, token }. It derives the public socket from the unified ingress (or from an explicit server-siderindle.wsUrl) and returns it on the lease. api.urldoesn’t reach your API server. Check the dev-server proxy (e.g. Vite’sserver.proxy["/api"]) points at the API server’s port.- The daemon isn’t running / migrations weren’t applied.
rindle statusandrindle migrate status.
schema.gen.ts errors / types don’t match the DB
- Someone hand-edited
schema.gen.ts. It’s generated and overwritten. Change the SQL migration instead, then re-runrindle schema gen(or letrindle dev --gen …regenerate on change). - Forgot to regenerate after a migration. Run
rindle schema gen --out shared/schema.gen.ts. (The daemon rejects a stale schema fingerprint on subscribe, so this fails loudly, not silently.) - A column is the wrong kind (e.g. a boolean reads as
number). SQLite kept the declared name — declare itBOOLEAN/JSON, not bareINTEGER/TEXT. - A migration was rejected.
RENAMEand column type changes are not supported — expand instead (add the new column/table, move writes, thenDROPthe old one).blobis also refused. Drops themselves are supported and print a[destructive]notice. (BIGINT/INT8are accepted — they declare the exact int64 plane. Live queries touching such a column are refused until the browser bigint lane ships, soselectthe other columns.) See Schema & migrations. - A migration mixes DDL and DML. Split it into ordered files: add the schema in
one file, then seed or backfill it with
INSERT/UPDATE/DELETEin the next. Reads, PRAGMAs, and transaction-control statements are not migration steps. - A data migration is too large. One file can capture at most 8,191 user-row changes and 64 MiB. Split the backfill into explicit key ranges. Each file is its own atomic, checksum-bound migration.
Optimistic writes flicker, double-apply, or drift after rebase
- A mutator is non-deterministic. It re-runs on every rebase — remove
Date.now(),Math.random(), and any I/O. Generate ids/timestamps at the callsite and pass them as args (the determinism rules). - A server override drifted from the shared body. The two tiers run the SAME
isomorphic generator, so the base case can’t disagree — but a hand-written
server entry (a policy guard, a raw
tx.execcascade) can. Keep the override’s effect a superset of the shared body: drive the shared body viarunSharedMutationand add only the server-only authority, so the prediction still matches the commit. - A read-dependent mutator was folded. A mutator that reads (
yield tx.row/tx.query) or isn’t absorbing (e.g.increment) must not use.folded(...). The folded path throws for readers. Route non-absorbing mutators through plainmutate.
A write is silently ignored (no error, nothing changes)
- Accepted-but-no-op is by design. If a server op matches no row (e.g. a raw
DELETE … WHERE id = ? AND ownerId = ?for a non-owner), the write is accepted and the optimistic change rebases away. If you meant to reject,throwin the mutator body (or a server guard) instead — a hard reject firesonRejected. The two shapes are contrasted in the API server. - Args failed server validation. The shared mutator’s
.argsschema parses the untrusted wire args before the body runs. Ifparse(raw)throws, it’s a hard reject — surface it viaonRejected.
A query throws BuildError when it materializes
You hit an unsupported shape. Check Supported query
shapes — common ones: root count() mixed with
select/sub/orderBy; a low-pass parent-by-child-count having; an exists
carrying start or a nested sub; sum/avg/min/max.
Auth / security smells
RINDLE_DATABASE_TOKENreached the browser. It is a database-wide credential. Keep it in the API server’s secret store. The browser must know only your API URL and the short-lived lease data that API returns.- Trusting a client-supplied owner/author. Identity is off-wire — the
actor is
ctx.user(the server injects its authenticated principal), never anowner/authorarg. The shared body already readsctx.user, so the server’ssharedCtxis the single place identity enters. - Not validating args on the server. The client’s prediction is a guess.
Every shared mutator carries its
.argsschema.sharedApiMutatorsparses the untrusted wire args through it before the body runs. A hand-written override must parse too.
SSR / wasm boot errors
- wasm constructed during server render. Never construct the optimistic
client during SSR/prerender. Defer: lazily
import("@rindle/optimistic")+import("@rindle/wasm")on the client and memoize the boot promise — see Server rendering.create-rindleapps ship this pattern insrc/rindle-client.ts.
A restart loses live queries
Expected — rindled keeps no durable materialization state. Wire onBootId on
the HttpRindleDaemonClient and re-assert pins (api.assertPins()) when it fires
— see pinned queries.
SQL points at the wrong service
Use the application ingress, not an internal master port or legacy replicator URL:
RINDLE_URL=https://app-… RINDLE_DATABASE_TOKEN=… rindle sql "select 1"
Application code uses the same values with createSqlClient({ url, authToken }). A
404 for /v1/sql usually means RINDLE_URL points at a follower/control endpoint
instead of the unified edge.
ECONNREFUSED on :7600 / :7611 / :7650 after upgrading
Local ports are no longer fixed. Each project gets its own 100-wide block, chosen from
the path of the directory holding rindle.ncl. Several Rindle projects — or several
git worktrees of one project — can therefore run at the same time. :7600 and friends
are not your fleet’s ports any more. See
Running several projects at once.
Take the URLs from the environment rindle dev injects, or from rindle.json’s
bindings — never a literal:
// not: process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600"
const daemonUrl = process.env.RINDLE_DAEMON_URL;
if (!daemonUrl) throw new Error("RINDLE_DAEMON_URL is required");
A hardcoded fallback is worse than a crash here: if another Rindle project happens to hold that port, the read succeeds against the wrong database. The CLI and the daemons fence themselves against that with a project fingerprint, but a browser or an app-tier HTTP client sends no identity and cannot be fenced.
To keep the old numbers instead, pin the block in rindle.ncl:
{ portBase = 7600 }
rindle render prints the resolved URLs for whichever block you get.
Performance: subscribing to too much
Subscribe to windows, not whole tables — orderBy + limit, and raise the
limit for “load more”. IVM keeps the window (and any countAs) exact as rows
enter and leave. Add SQL indices for the directions your joins and windows
traverse — see Performance.
Next steps
- Isomorphic mutators — the write contract most of these rules protect.
- Supported query shapes — what the builder can and can’t express.
- The API server — validation, authority, and the two rejection shapes.
- Run the daemon — ports, planes, and restart recovery.