# 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.

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](/docs/schema).
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](/docs/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](/docs/schema).

## 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](/docs/mutators#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](/docs/api-server#driving-the-shared-mutators).
- **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](/docs/supported-queries-ts) — 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](/docs/ssr); [`create-rindle`](/docs/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](/docs/api-server#pinned-queries-the-one-shot-read).

## 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](/docs/performance).

## Next steps

- [Isomorphic mutators](/docs/mutators) — the write contract most of these rules
  protect.
- [Supported query shapes](/docs/supported-queries-ts) — what the builder can and
  can't express.
- [The API server](/docs/api-server) — validation, authority, and the two
  rejection shapes.
- [Run the daemon](/docs/daemon) — ports, planes, and restart recovery.

---

[View this page on Rindle](https://rindle.sh/docs/troubleshooting)
