UNPKG

seitu

Version:
200 lines (149 loc) • 7.99 kB
# createIndexedDb Single owner of an IndexedDB connection. Takes `createIndexedDbStorage` / `createIndexedDbTable` definitions under `stores` (the key is the object store name), builds one handle per definition under `db.stores`, creates missing stores and indexes, bumps the version automatically, and serializes concurrent databases with the same name so they never race during an upgrade. ```ts import { createIndexedDb, createIndexedDbStorage, createIndexedDbTable, } from 'seitu/web' import * as z from 'zod' const db = createIndexedDb({ name: 'app', stores: { settings: createIndexedDbStorage({ schemas: { theme: z.string() }, defaultValues: { theme: 'light' }, }), todos: createIndexedDbTable({ keyPath: 'id', indexes: { status: 'status', order: 'order' }, schema: z.object({ id: z.string(), status: z.enum(['open', 'done']), order: z.number(), }), }), }, }) const { settings, todos } = db.stores await db.ready ``` ## Options | Option | Type | Description | | --- | --- | --- | | `name` | `string` | Database name | | `stores` | `Record<string, IndexedDbStore>` | Storage/table definitions keyed by object store name | | `version?` | `number` | Minimum version. Bumped automatically when a store/index is missing | | `onUpgrade?` | `(ctx) => void` | Runs inside the `versionchange` transaction after stores/indexes are created. For data migrations | ## Migrations Bump `version` when existing rows need rewriting (new stores and indexes bump it on their own), then rewrite rows with `ctx.migrate`: ```ts const db = createIndexedDb({ name: 'app', version: 2, // v1 rows have no `priority` stores: { todos: createIndexedDbTable({ keyPath: 'id', schema }) }, onUpgrade: ({ oldVersion, migrate }) => { if (oldVersion < 2) { migrate('todos', (row, key) => ({ ...row, priority: row.priority ?? 0 })) } }, }) ``` `migrate(storeName, rewrite)` walks every row of a declared store with a cursor. Store names and row types come from `stores`; the row is also indexable by any key, so fields the current schema dropped are still readable. Return the new row, `null` to delete it, or nothing to keep it. Everything in `onUpgrade` is synchronous: `migrate` queues the cursor walk and returns `void`, `onUpgrade` cannot be `async`, and a throw inside `rewrite` aborts the `versionchange` transaction, so the database is never half-migrated. `transaction` and `database` stay on the context for raw IndexedDB work. Limits of the cursor walk: - **Fields, not keys.** Returning a row whose `keyPath` field changed aborts the upgrade (`cursor.update` rejects a key that differs from the cursor's). Rekey by deleting and re-`put`ting after `ready`. - **Indexes are already filled.** They are created before `onUpgrade` runs, so a `unique` index added in the same version fails on duplicate legacy rows before the migration can dedupe them. Clean up in one version, add the index in the next. - **Failure is warn-only.** An aborted upgrade rolls back whole, logs `console.warn`, and leaves the database on its old version. `ready` still resolves, but every later read and write rejects, because each one retries the same failing upgrade. Object store shape (`keyPath`, `autoIncrement`, `indexes`) lives on the table definition, not here. A storage is always an out-of-line key/value store. ## Transactions There is no transaction handle to open. Every write call is exactly one `readwrite` transaction over exactly one store, and every read is one `readonly` snapshot. ```ts // One transaction: both rows land, or neither does. await todos.put([ { id: '1', title: 'Write docs', status: 'done' }, { id: '2', title: 'Ship docs', status: 'open' }, ]) await todos.delete(['1', '2']) await sync.set({ lastSyncedAt: Date.now(), pending: 0 }) // Two calls are two transactions: the second can fail with the first applied. await todos.put({ id: '3', title: 'Reconcile', status: 'open' }) await sync.set({ pending: 1 }) ``` - Batch a write by passing the array to one call, never by awaiting one call per row: rows are validated before the transaction opens (a bad row rejects with nothing written), and subscribers plus other tabs are notified once per call. - Nothing spans stores. Order cross-store writes to fail safe — write the rows first, then the pointer that makes them visible. - A table write rejects when its transaction aborts. A storage write never rejects: the cache already holds the new value and a failed persist is `console.warn` only, so confirm through `hydrate()` when it matters. - The one wide transaction is `versionchange`: everything `onUpgrade` queues shares it, which is why a failed migration rolls back whole. ## Interface ```ts interface IndexedDb<Stores> { ready: Promise<void> close: () => void stores: { [K in keyof Stores]: IndexedDbStorage<...> | IndexedDbTable<...> } '~': { name: string; getDatabase: () => Promise<IDBDatabase> } } interface IndexedDbStoreHandle { db: IndexedDb storeName: string hydrate?: () => Promise<unknown> } interface IndexedDbStore<Handle> { '~': { definition: { keyPath?; autoIncrement?; indexes? } create: (db: IndexedDb, storeName: string) => Handle } } ``` ## Behaviour - Builds every handle synchronously in the constructor. A definition is reusable: the same one can back several stores or databases, each gets its own handle. - Opens eagerly in the browser; `ready` also awaits every storage's first hydrate. Tables have nothing to hydrate. - Missing store or index → closes, reopens at `version + 1`, creates it in `onupgradeneeded`. - `version` is a **minimum**, not a pin. A pinned version lower than the existing one is recovered automatically. - `onUpgrade` receives `migrate(storeName, rewrite)` for cursor-based row migrations (see [Migrations](#migrations)). - Another connection upgrading the database → this handle closes and reopens on next access (`onversionchange`). - Upgrade blocked by a foreign connection → `console.warn`, then waits. - Two databases with the same `name` created in the same tick are serialized through a module-level lock. - SSR (no `indexedDB`): `ready` resolves, `getDatabase()` rejects. ## Common Mistakes ### [HIGH] Using the definition as the handle Wrong: ```ts const todos = createIndexedDbTable({ keyPath: 'id', schema }) createIndexedDb({ name: 'app', stores: { todos } }) await todos.put(row) // definition has no put ``` Correct: ```ts const db = createIndexedDb({ name: 'app', stores: { todos: createIndexedDbTable({ keyPath: 'id', schema }) }, }) await db.stores.todos.put(row) ``` `createIndexedDbStorage` / `createIndexedDbTable` return definitions with no public API. Handles live under `db.stores`. ### [HIGH] Declaring the same database in several places with different stores Wrong: ```ts const a = createIndexedDb({ name: 'app', stores: { a: createIndexedDbStorage(...) } }) const b = createIndexedDb({ name: 'app', stores: { b: createIndexedDbStorage(...) } }) ``` Correct: ```ts const db = createIndexedDb({ name: 'app', stores: { a: createIndexedDbStorage(...), b: createIndexedDbStorage(...) } }) ``` It works (stores get added on the fly) but every extra declaration costs a version bump and a reconnect. Declare once. ### [MEDIUM] Indexing a boolean Wrong: ```ts createIndexedDbTable({ keyPath: 'id', indexes: { done: 'done' }, schema }) // done: boolean ``` Correct: ```ts createIndexedDbTable({ keyPath: 'id', indexes: { status: 'status' }, schema }) // status: 'open' | 'done' ``` Booleans are not valid IndexedDB keys; such rows silently never appear in the index. Use strings, numbers, dates, or arrays. ## See also - [`create-indexed-db-storage`](create-indexed-db-storage.md) — key/value store definition. - [`create-indexed-db-table`](create-indexed-db-table.md) — row-based store definition with indexes and reactive queries. ## Source `src/web/indexed-db/index.ts`