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:
writeLocalaccepts 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
- Persisting local tables — make a
{ local: true }table survive reloads and stay coherent across tabs with one client option. - The browser client — the local-table API in full.
- Schema & migrations — where local tables sit next to your generated schema.