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

A [local-only table](/docs/local-only-tables) 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:

```ts
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" }`:

```ts
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:

```ts
import { deleteLocalPersistence } from "@rindle/optimistic";

await deleteLocalPersistence(currentUserId);   // close this user's client first
```

## In the wild — Strut

[Strut](https://github.com/tantaman/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:

```ts
// 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](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/rindle/localSchema.ts#L21-L26) · 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:

```ts
// src/rindle/client.ts
const app = await createRindleClient({
  schema: clientSchema,
  mutators,
  user: () => sessionUserId,
  persistLocal: { user: sessionUserId || "anon" },
  // …
});
```

*[`src/rindle/client.ts` L81–86](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/rindle/client.ts#L81-L86) · 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:

```ts
// 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](https://github.com/tantaman/strut/blob/cac07f41d5b13233c26d89bf25ac55745295d4ac/src/rindle/deckPref.ts#L21-L59) · 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](/docs/local-only-tables) — declaring, writing, and composing local tables (start
  here).
- [The browser client](/docs/client#local-only-tables-drafts-selections-prefs) — the local-table and
  persistence API in full.

---

[View this page on Rindle](https://rindle.sh/docs/persisting-local-tables)
