| name | idb |
| description | Jake Archibald's `idb` v8 wrapper for Snapmatch — the validated, namespaced durable local replica and exact command outbox between Firestore and Jotai, plus device-local seed state. |
| license | MIT |
Owns every byte that touches IndexedDB. Device settings/sample progress use the local-only
atomWithIDB path. Remote-owned records use the implemented Auth/World/PlayerSession/Game namespace,
committed replica, durable command outbox, exact receipt, checkpoints, tombstones, quarantine, and
recovery stores, plus the durable retirement-intent barrier. Firestore is committed authority and
Jotai is published only after the owning IDB transaction commits. Production domain synchronization
remains disabled independently of this implemented local boundary.
When to invoke
- Authoring
apps/<app>/app/state/db.ts (the singleton openDB<AppDB> call).
- Adding a new object store or index, or bumping the schema version with a migration.
- Wiring the root
idbHydrationPromise into the <Suspense> boundary that fronts the whole app.
- Wiring the
BroadcastChannel that re-hydrates other tabs / Storybook iframes after a write.
- Extending Firestore snapshot → Zod → IDB reconciliation or local command → IDB outbox boundaries.
- Diagnosing a "transaction has finished" error or a migration that ran on the wrong oldVersion.
- Writing a unit test (in
bun test) for a migration's transform function. Ask the feature owner
before adding or changing the real-browser Playwright upgrade integration.
Owns
The idb library wrapper around IndexedDB: openDB, object stores, indexes, transactions,
schema-versioned upgrade migrations, the local-only atomWithIDB storage adapter, the root
hydration promise consumed by use(), and atomic replica/outbox/checkpoint/receipt persistence. It
does not own Firestore transport or the domain conflict reducer.
Defers to
zod — for how to author the record schemas this skill validates. Each object store has a z.object per record shape; safeParse runs inside the upgrade callback to rescue or drop bad rows.
jotai — for the atomWithIDB(schema, key, default) factory itself. idb owns the IDB primitives the factory calls; jotai owns the atom contract.
- The Firestore synchronization layer — for subscriptions, command delivery, acknowledgements,
retry policy, and authoritative conflict resolution. IDB persists its inputs and outcomes.
bun-test — for unit-testing migration transform functions (pure (oldRow) => newRow logic).
The real openDB upgrade path belongs in owner-approved Playwright coverage.
Snapmatch stack rules
- Local-only state: generated seed/progress state remains IDB-local, so its existing
idbHydrationPromise and direct write-through behavior remain accurate. connectionChecks/web is
only a Firestore connectivity sentinel and is not a replicated game-state store.
- Remote-owned state: Firestore is authoritative; IDB is the last validated committed replica,
durable pending-command outbox, and recovery record. Do not describe IDB as remote authority.
- Replica and tombstone stores use compound
[namespaceKey, recordKey] physical keys. A canonical
Firestore path may exist in both lobby and Game lanes; never key or hydrate those rows by
recordKey alone.
- The current remote-state database is version 5. Version 4 introduced the compound replica and
tombstone key paths through a validating v3 rescue migration; version 5 adds the
retirementIntents store. Never copy a legacy key without parsing the row.
- Every durable namespace-bearing row must use the canonical key derived from Auth UID, World,
PlayerSession, and Game-or-lobby identity. Replica values, entity IDs, and record keys must agree
with the canonical Firestore path; a structurally valid alias is still invalid durable state.
- Remote ingress order is strict: Firestore snapshot → Zod parse → atomic IDB replica write → Jotai
reconciliation. Invalid remote data never reaches the committed replica or Jotai; only bounded,
typed quarantine evidence may be retained in IDB.
- An invite deep link is exact remote ingress, not a component-owned shortcut. The repository reads
the addressed Game document, parses it with Zod, commits the lobby replica and sync checkpoint in
one IDB transaction, and only then reconciles Jotai. The invite route may request that operation
through the application facade; it never writes the fetched Game directly to an atom.
- Local egress persists a validated command and projection checkpoint atomically in IDB first. Only
after commit is the optimistic view published to Jotai. A network loop reads the outbox outside any
IDB transaction, writes Firestore, then records the exact acknowledgement/rejection and reconciles
against the authoritative replica.
- The single root hydration promise still migrates and reads IDB before atoms mount. It provides
immediate offline state, not proof that the cached state is newer than Firestore. Before
publication it atomically deletes expired dead-letter and terminal outbox rows, stored receipts,
tombstones, and RecoveryRecords at their typed retention boundary. Unexpired RecoveryRecords are
cleanup-only evidence and are not included in the published hydration snapshot. Hydration unions
every version-5 retirement intent with the strict sessionStorage
retirement-pending marker and
suppresses all owned pointers, domain rows, quarantine evidence, and hydration issues before
Jotai. Invalid-row ownership falls back to compound/checkpoint physical keys or the pointer's
tab-claim hash, so malformed values cannot evade suppression. This local filter runs even when
domain synchronization is disabled.
- Hydration and runtime repair must re-read an observed row inside the write transaction and delete
or quarantine it only if the value still matches. A newer cross-tab replacement survives, and
only rows actually moved to quarantine may be published as hydration issues.
Runtime quarantine records the canonical namespace supplied by the active lane so namespace
enumeration and retirement can remove it.
- Session retirement scans every namespace store for the owning Auth/World/PlayerSession before
deleting data. The current pointer is insufficient because a historical Game lane remains owned
after the pointer returns to lobby. Each retired namespace retains exactly one sanitized,
non-rendered RecoveryRecord for one hour. A pass attempts every owned namespace even if one
fails. The version-5 retirement intent is removed only after all succeed; reload and replacement
bootstrap drain unfinished intents before acquiring a new principal.
- First arrival creates a UUID PlayerSession and derives its
PLAYER.##### display label. The
resumable same-tab claim is browser-cached before shared state renders; the public session still
reaches Firestore only through the durable session-command choreography. A direct invite arrival
uses the same identity bootstrap before exact Game hydration.
- Active PlayerSession, Game, Participant, and Vote replicas carry their server-scheduled, non-null
purgeAt. Pending Games also carry the authoritative 60-second pendingEndsAt. IDB may render
those records only while their logical deadline/state is valid; TTL timing is never treated as
activity or membership evidence. A terminal snapshot resets purgeAt to exactly one hour after
its logical end.
- Pillar 2 (Zod-first types) means: every record's TS type is
z.infer<typeof RecordSchema> (see zod). The upgrade callback re-parses old rows against the new schema and treats parse failure as data loss to surface (not silently swallow).
- Pillar 4 (CLI-gate-first) means: a Zod parse failure during hydration or a thrown migration in dev surfaces in the browser console and blocks
bun run dev. Migration transforms are unit-tested in bun test; the real upgrade path belongs in owner-approved Playwright coverage and is not currently covered (playwright-app-tests, forward).
- The service worker never touches IDB. IDB is application code.
- Never
await a non-IDB Promise (fetch, setTimeout, postMessage) inside an open transaction — the tx auto-closes on the next microtask turn with no IDB work pending.
Patterns
Authority and implementation status
| State surface | Implemented ownership |
|---|
connectionChecks/web | Operational Firestore telemetry, never a replica row |
| Device settings/sample progress | IDB-local source of truth; never uploaded as profile/history |
| World/session/game/participant/vote/catalog records | Firestore authority, Zod-validated into a namespaced committed IDB replica |
| Pending local commands | Atomic IDB outbox with stable command IDs, deadlines, retry state, and exact receipts |
| Jotai | Optimistic projection published only after IDB commits and reconciled from durable state |
Do not infer Auth or App Check from a successful sentinel read. Those are separate security
checkpoints.
Remote ingress and outbox transaction boundaries
Keep network awaits outside IDB transactions:
const event = RemoteSnapshotEventSchema.parse(decodedSnapshot);
await applyAuthoritativeState({
namespace,
events: [event],
receipt,
projectionCheckpoint,
syncCheckpoint,
observedAt,
});
publishDurableProjection(await readNamespaceState(namespace.namespaceKey));
await persistOptimisticCommand({ entry: outboxEntry, checkpoint: projectionCheckpoint });
publishDurableProjection(await readNamespaceState(namespace.namespaceKey));
await sendCommandToFirestore(outboxEntry.command);
The outbox record needs a stable command ID, target aggregate ID, base remote version/update time,
payload, creation time, status, and retry metadata. The authoritative acknowledgement or snapshot
decides whether to confirm, rebase, or reject the optimistic projection; IDB merely stores the facts.
Create/join establishment commands use the same durable transaction, but their prospective Game
namespace is sender-only and must not be published to Jotai before confirmed membership.
The room model is bounded to eight participants (two performers and at most six audience), and
join/start admission cannot be accepted at or beyond the Game's authoritative 60-second
pendingEndsAt; leave/abort cleanup remains valid afterward.
Current local-only singleton open with typed schema, version, and lifecycle callbacks
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
export interface AppDB extends DBSchema {
progress: {
key: string;
value: { id: string; score: number; round: number; completed: boolean };
};
settings: {
key: string;
value: {
id: "settings";
theme: "light" | "dark" | "system";
reducedMotion: boolean;
soundEnabled: boolean;
};
};
}
const DB_NAME = "snapmatch";
const DB_VERSION = 2;
let dbPromise: Promise<IDBPDatabase<AppDB>> | undefined;
export function getDB(): Promise<IDBPDatabase<AppDB>> {
if (dbPromise) return dbPromise;
dbPromise = openDB<AppDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, _newVersion, tx) {
switch (oldVersion) {
case 0: {
db.createObjectStore("progress", { keyPath: "id" });
}
case 1: {
const settings = db.createObjectStore("settings", { keyPath: "id" });
void settings.put({
id: "settings",
theme: "light",
reducedMotion: false,
soundEnabled: true,
});
}
}
},
blocked() { console.warn("idb: blocked by an older connection"); },
blocking() { void getDB().then((db) => db.close()); dbPromise = undefined; },
terminated() { dbPromise = undefined; },
});
return dbPromise.catch((err) => { dbPromise = undefined; throw err; });
}
Two equivalent forms; pick one — both run every hop cumulatively:
switch (oldVersion) with deliberate fall-through (canonical, but trips Biome's noFallthroughSwitchClause — needs a // biome-ignore comment).
if (oldVersion < N) cascading checks (Biome-clean, what snapmatch uses today). Each if runs for every user below that version, achieving the same cumulative effect.
Never use if (oldVersion === N) — that fails users on older versions.
Current remote-store v5 and v3-to-v4 rescue migration
apps/<app>/app/state/db.ts currently opens IDB version 5. For an existing version-3 database, the
v3-to-v4 versionchange hop reads all legacy replicaRecords and tombstones, deletes and recreates
those two stores with keyPath: ["namespaceKey", "recordKey"], and runs the pure transforms in
state/migrations/v3-to-v4.ts. Each transform parses the full shared record schema before deriving
the new compound key. Parse failures become bounded invalid-local-row quarantine records; a
failure to read or rebuild the stores aborts the upgrade. The cumulative v4-to-v5 hop adds
retirementIntents, keyed by tabClaimIdHash, so unfinished identity retirement survives reload.
Fresh databases create the compound stores and retirement store directly.
Root hydration promise — resolves the local replica once
import * as z from "zod";
import { getDB } from "./db";
const ProgressRecord = z.object({
id: z.string().min(1),
score: z.int().min(0),
round: z.int().min(1),
completed: z.boolean(),
});
const SettingsRecord = z.object({
id: z.literal("settings"),
theme: z.enum(["light", "dark", "system"]),
reducedMotion: z.boolean(),
soundEnabled: z.boolean(),
});
export type HydratedState = {
progress: ReadonlyMap<string, z.infer<typeof ProgressRecord>>;
settings: z.infer<typeof SettingsRecord>;
};
export const idbHydrationPromise: Promise<HydratedState> = (async () => {
const db = await getDB();
const [rawProgress, rawSettings] = await Promise.all([
db.getAll("progress"),
db.get("settings", "settings"),
]);
const progress = new Map(
rawProgress
.map((r) => ProgressRecord.safeParse(r))
.filter((r) => r.success)
.map((r) => [r.data.id, r.data] as const),
);
const settings = SettingsRecord.parse(
rawSettings ?? {
id: "settings",
theme: "light",
reducedMotion: false,
soundEnabled: true,
},
);
return { progress, settings };
})();
Started at import time so the promise is already in flight before React mounts. The <Suspense>
boundary at the app shell calls use(idbHydrationPromise) once; after that, every current
atomWithIDB reads its initial value synchronously from this snapshot—see jotai. In the remote
flow, hydration exposes the last eligible, non-retiring local replica immediately and the Firestore
subscription reconciles it afterward.
Current local-only debounced write-through (~150ms) + BroadcastChannel re-hydration
import { getDB } from "./db";
const channel = new BroadcastChannel("snapmatch:idb");
const pending = new Map<string, NodeJS.Timeout>();
export function persistProgress(value: {
id: string;
score: number;
round: number;
completed: boolean;
}) {
const key = `progress:${value.id}`;
clearTimeout(pending.get(key));
pending.set(
key,
setTimeout(async () => {
const db = await getDB();
await db.put("progress", value);
channel.postMessage({ store: "progress", key: value.id });
}, 150),
);
}
export function subscribeRemoteWrites(onChange: (msg: { store: string; key: string }) => void) {
const handler = (e: MessageEvent) => onChange(e.data);
channel.addEventListener("message", handler);
return () => channel.removeEventListener("message", handler);
}
The BroadcastChannel name is stable across tabs/iframes; jotai subscribes and re-runs hydration for
the affected store. For remote-owned records, broadcast only after the replica/outbox
transaction commits. A broadcast is local invalidation, never a Firestore acknowledgement.
The current helper name subscribeRemoteWrites means “another browser context”; prefer
subscribeReplicaWrites in the remote-state implementation so it cannot be confused with a
Firestore subscription.
Atomic multi-store write
const tx = db.transaction(["progress", "settings"], "readwrite");
await Promise.all([
tx.objectStore("progress").put({ id, score, round, completed }),
tx.objectStore("settings").put({
id: "settings",
theme,
reducedMotion,
soundEnabled,
}),
tx.done,
]);
tx.done is the only signal that all writes committed. If any operation rejects, the whole transaction aborts.
Async iteration on an index
const tx = db.transaction("progress");
const since = IDBKeyRange.lowerBound(1);
for await (const cursor of tx.store) {
if (cursor.value.round < 1) cursor.delete();
}
await tx.done;
v8 ships async iterators in the default build — never import from idb/with-async-ittr (removed in 8.x).
Migration transform — unit-testable function
export function migrateProgressV1toV2(old: { id: string; score: number; round: number }) {
return { ...old, completed: false };
}
The pure function ships with a sibling *.test.ts file (see bun-test). A real-browser upgrade test
belongs in Playwright, but the repository's ASK-FIRST rule requires feature-owner approval before
adding or changing it. Do not describe transform coverage as proof of the native browser upgrade.
Anti-patterns
- Don't open more than one connection —
getDB() is a module-level singleton. Multiple connections fight over the version-change lock.
- Don't
await fetch (or any non-IDB Promise) inside an open transaction — the tx auto-closes on the next microtask, and the next IDB call throws "transaction has finished." Read → close tx → fetch → open new tx → write.
- Don't apply a Firestore snapshot directly to Jotai — parse it with Zod and commit it to IDB
first; then reconcile the in-memory projection from the durable local replica.
- Don't delete an outbox row merely because a network request resolved — persist an authoritative
acknowledgement/version or reconcile the matching Firestore snapshot first.
- Don't key replicas or tombstones by
recordKey alone — concurrent lobby and Game lanes may
contain the same Firestore path. Version 4 requires [namespaceKey, recordKey].
- Don't reuse the current
connectionChecks/web sentinel as a world/game/player store — it proves
the SDK, Rules, and Hosting path can connect; it is not the remote data model.
- Don't write
if (oldVersion === N) in upgrade — use switch (oldVersion) with intentional fall-through so a user upgrading 1→3 runs the 1→2 and 2→3 paths.
- Don't open a fresh transaction inside
upgrade — use the tx argument; it's the live versionchange transaction.
- Don't import from
idb/with-async-ittr — removed in v8. Async iterators are bundled in idb.
- Don't put durable state in
useState if losing it on a reload would be noticed — use the
atomWithIDB path for local-only state, or the Jotai command → IDB replica/outbox
path for remote-owned state (see jotai).
- Don't touch IDB from the service worker — assets are the SW's job; IDB is application code.
- Don't validate IDB records with hand-written types — every record is parsed against a
z.object schema (see zod); a safeParse failure on hydration is data loss to surface.
Triggers on
atomWithIDB storage, BroadcastChannel sync, idb, IDBPDatabase, idb hydration, idb migration, idb migration test, IndexedDB, object store, openDB, upgrade callback