Recipes

Local-only tables

Give drafts, selections, and view preferences the same reactive query machinery as synced rows — declare a `{ local: true }` table, write it with `store.writeLocal`, and compose it with synced data locally. It never crosses the wire, and an optimistic rebase never touches it.

View as Markdown

Not every row belongs to the server. Draft text, the current selection, expanded/collapsed state, a scratch thread — this is client-authoritative state that wants the same reactive query engine as your synced rows but should stay private to the device and survive server confirmations unchanged. Declare those tables { local: true } and they live in the same store, queryable and reactive, but they never sync.

Declare it, and keep the generated schema generated

A local table is an ordinary typed table def with one option. Because your synced schema is generated from SQL and shouldn’t be hand-edited, put local tables in a small module and fold them in with extendSchema:

// schema.local.ts
import { extendSchema, string, table } from "@rindle/client";
import { schema as generatedSchema } from "./schema.gen.ts";   // generated — never hand-edited

export const draft = table("draft", { local: true })
  .columns({ id: string(), issueId: string(), body: string() })
  .primaryKey("id");

export const clientSchema = extendSchema(generatedSchema, { tables: [draft] });

Hand clientSchema to createRindleClient in the browser. extendSchema accepts only local tables, so your API server and named-query registry keep using the plain generated schema — real synced tables stay SQL-first and can’t drift.

Write it with store.writeLocal

Local tables are written directly, outside the optimistic mutation stack:

await app.store.writeLocal((tx) => {
  tx.add("draft", { id: "compose", issueId: "42", body: "half-written reply" });
});
// later — the keyed tx exposes add / edit / remove:
await app.store.writeLocal((tx) => tx.edit("draft", prev, { ...prev, body: text }));

Read it — and compose it with synced rows

store.query materializes a local table exactly like a synced one, and can join it against synced rows locally:

const drafts = store.query.draft.where.issueId("42").materialize();
drafts.data; // reactive, updates on every writeLocal

Two guardrails keep private and synced state apart:

  • writeLocal accepts only local tables — writing a synced table there is rejected (use a mutator).
  • Mutators can’t read or write local tables. A mutator replays from (name, args) on the server and again on every rebase, so it must not depend on private browser rows. Likewise, a named server query can’t reference a local table — your remote contract never accidentally depends on UI state.

That last property is the point: because an optimistic rebase rewinds and replays synced state only, your draft text and selection sit completely still while the server confirms writes around them.

Durable or ephemeral

{ local: true } is the durable-capable variant: when the client turns on persistence it survives reloads and stays coherent across tabs (see Persisting local tables). For state that must stay per-tab and vanish on reload even with persistence on — a scratch thread, transient selection — declare it { local: "session" } instead. Both are equally invisible to the server; they differ only in durability.

In the wild — Strut

Strut is a slide-deck editor built on Rindle. Its “✨ Chat” advisor thread is a local table — private per device, never synced to co-editors, and deliberately ephemeral ({ local: "session" }), because an advisor bounce doesn’t need durable history:

// src/rindle/localSchema.ts
export const chatMessage = table("chat_message", { local: "session" })
  .columns({
    id: string(),
    deck_id: string(),
    role: string<"user" | "assistant">(),
    content: string(),  // grows per token for a streaming assistant turn
    status: string<"streaming" | "done" | "error">(),
    note: string(),
    created: number(),
  })
  .primaryKey("id");

export const clientSchema = extendSchema(schema, { tables: [deckPref, chatMessage] });

src/rindle/localSchema.ts L37–66 · Strut

The panel streams an assistant turn straight into that table with store.writeLocal — a keyed add for the two initial rows, then a keyed edit per token — and reads it back with a plain store.query, no server round-trip:

// src/editor/aiChat.ts
await store.writeLocal((tx) => {
  tx.add("chat_message", userRow);
  tx.add("chat_message", assistantRow);
});
// … then per streamed token:
store.writeLocal((tx) => tx.edit("chat_message", prev, next));

// the live thread, materialized locally:
store.query.chat_message.where.deck_id(deckId).orderBy("created", "asc").materialize();

src/editor/aiChat.ts L223–237 (write) · L776–795 (read) · Strut

Strut’s other local table, deck_pref, is the durable variant — see the next recipe.

See also