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 may not immediately catch.
- SQL is the source of truth; the TS schema is generated. Evolve the schema by
adding a migration (additive DDL only), 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 ratchet the limit up for “load more”. - The daemon token is server-only. It gates the private control plane
(
:7600); the browser only ever holds the lease-gated public WebSocket (:7601). - 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. daemon.wsUrlpoints at the wrong port. The browser subscribes to the public ws port (7601), not the HTTP control plane (7600).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 up --watchregenerate). - 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. v1 DDL is additive only — no
DROP/RENAME/ type change. Fix forward with a new additive migration.bigint/blobare also refused. See Schema & migrations.
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
- The daemon token reached the browser. It must stay server-only (it gates
:7600). The browser holds only the lease-gated ws. Keep it in the API server’s env/secret. - 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.
Performance: subscribing to too much
Subscribe to windows, not whole tables — orderBy + limit, and ratchet the
limit up 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.