Recipes

Persisting local tables

Turn on `persistLocal` and every `{ local: true }` table survives reloads and stays coherent across this device's tabs — backed by IndexedDB with a Web-Locks leader, restored before first paint. Opt a table out with `{ local: "session" }`; clear it on logout with `deleteLocalPersistence`.

View as Markdown

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 createRindleClient resolves, 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").

persistLocal is 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