Reference

Troubleshooting

The rules that keep a Rindle app correct, and the ways an app goes subtly wrong when one is broken — sync that never starts, schema drift, optimistic flicker, silent writes, and auth smells, each with its fix.

View as Markdown

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.

  1. 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.
  2. 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.
  3. Mutators must be deterministic and replayable. No Date.now(), no Math.random(), no I/O — ids and timestamps arrive as args, and the acting user is ctx.user, never a client-supplied arg.
  4. 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 .args schema and each query’s validator.
  5. Remote subscriptions must be named. Only a defineQuery value opens a server subscription; a bare builder query resolves locally.
  6. Subscribe to windows, not whole tables. Order + limit, and ratchet the limit up for “load more”.
  7. The daemon token is server-only. It gates the private control plane (:7600); the browser only ever holds the lease-gated public WebSocket (:7601).
  8. Keep *.queries.ts modules 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 defineQuery value opens a server subscription. A bare store.query.<table>.where… builder resolves locally only — it renders off already-synced rows and never pulls new data. Wrap it in defineQuery, call myQuery(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.wsUrl points at the wrong port. The browser subscribes to the public ws port (7601), not the HTTP control plane (7600).
  • api.url doesn’t reach your API server. Check the dev-server proxy (e.g. Vite’s server.proxy["/api"]) points at the API server’s port.
  • The daemon isn’t running / migrations weren’t applied. rindle status and rindle 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-run rindle schema gen (or let rindle up --watch regenerate).
  • 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 it BOOLEAN/JSON, not bare INTEGER/TEXT.
  • A migration was rejected. v1 DDL is additive only — no DROP/RENAME/ type change. Fix forward with a new additive migration. bigint/blob are 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.exec cascade) can. Keep the override’s effect a superset of the shared body: drive the shared body via runSharedMutation and 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 plain mutate.

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, throw in the mutator body (or a server guard) instead — a hard reject fires onRejected. The two shapes are contrasted in the API server.
  • Args failed server validation. The shared mutator’s .args schema parses the untrusted wire args before the body runs; if parse(raw) throws, it’s a hard reject — surface it via onRejected.

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 an owner/author arg. The shared body already reads ctx.user, so the server’s sharedCtx is the single place identity enters.
  • Not validating args on the server. The client’s prediction is a guess. Every shared mutator carries its .args schema; sharedApiMutators parses 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-rindle apps ship this pattern in src/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