A local-only table is per-session by default: great for scratch state, but
device preferences — panel layout, last-used tool, a “don’t show this again” flag — want to survive a
reload. One client option makes every { local: true } table durable and coherent across this
device’s tabs, with no change to how you read or write it.
Turn it on
Pass persistLocal to createRindleClient with a stable storage identity:
const app = await createRindleClient({
schema: clientSchema,
mutators,
// …
persistLocal: { user: currentUserId }, // one IndexedDB database per (origin, user)
});
That’s the whole API surface. Now every { local: true } table is:
- Durable — persisted to IndexedDB and restored before
createRindleClientresolves, so a local view can render its rows on first paint. - Cross-tab coherent — a single Web-Locks leader tab owns the database and sequences commits; other tabs mirror its writes live. Open the app in two tabs and a local write in one appears in the other.
- Crash-safe — writes are idempotent full-row state, so a leader handoff, a resent write, or a restore-during-stream all converge instead of corrupting.
user is the storage identity, fixed for the client’s lifetime — one database per (origin, user).
It is not the mutator principal; it’s just how a shared machine keeps two people’s local state
separate. An anonymous session passes its own sentinel (e.g. "anon").
persistLocalis opt-in and degrades, never throws. Where IndexedDB or Web Locks are unavailable (private-mode quirks, older Safari/Firefox), the layer runs inert: local tables keep working, session-scoped, exactly as if persistence were off. The write path never fails because durability did.
Opt a table out: { local: "session" }
Persistence is per-table, keyed off the locality marker. A table you want to stay ephemeral and
per-tab even with persistLocal on — a scratch thread, a transient selection that shouldn’t follow
the user across tabs — declares { local: "session" }:
export const composerDraft = table("composerDraft", { local: true }) // persisted + cross-tab
.columns({ id: string(), body: string() }).primaryKey("id");
export const selection = table("selection", { local: "session" }) // ephemeral, per-tab
.columns({ id: string(), rowId: string() }).primaryKey("id");
Everything else about the two is identical — both are invisible to the server and untouched by an optimistic rebase. Only durability differs.
Clear it on logout
The database is deliberately not wiped when a client closes (fast re-login is a feature, and it’s a privacy decision the app owns). On an explicit logout, call the sanctioned hook:
import { deleteLocalPersistence } from "@rindle/optimistic";
await deleteLocalPersistence(currentUserId); // close this user's client first
In the wild — Strut
Strut persists per-deck editor preferences on the device.
deck_pref is a { local: true } table keyed by deck; today it remembers whether the ✨ Chat panel
was open, so reopening a deck lands you where you left it instead of always closed:
// src/rindle/localSchema.ts
export const deckPref = table("deck_pref", { local: true })
.columns({
deck_id: string(),
chat_open: boolean(), // was the ✨ Chat panel open when you last left this deck?
})
.primaryKey("deck_id");
src/rindle/localSchema.ts L21–26 · Strut
The client turns persistence on with the session’s user id — resolved during boot, so it’s the stable storage identity for the client’s lifetime:
// src/rindle/client.ts
const app = await createRindleClient({
schema: clientSchema,
mutators,
user: () => sessionUserId,
persistLocal: { user: sessionUserId || "anon" },
// …
});
src/rindle/client.ts L81–86 · Strut
Because that flips durability on for every local: true table, Strut’s ephemeral advisor thread
(chat_message) opts out with { local: "session" } — the same feature, pointed the other way. And
the pref itself is read and upserted with the ordinary local API — a materialized store.query, and a
store.writeLocal that edits the existing row or adds the first one:
// src/rindle/deckPref.ts
const view = store.query.deck_pref.where.deck_id(deckId).materialize();
// …
store.writeLocal((tx) => {
if (current) tx.edit("deck_pref", current, { ...current, chat_open: value });
else tx.add("deck_pref", { deck_id: deckId, chat_open: value });
});
src/rindle/deckPref.ts L21–59 · Strut
Nothing in the read or write path knows the row is persisted — locality is a schema fact, and
persistLocal is the one switch that acts on it.
See also
- Local-only tables — declaring, writing, and composing local tables (start here).
- The browser client — the local-table and persistence API in full.