{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-indexed-db",
  "title": "useIndexedDb",
  "description": "An async IndexedDB-backed store hook: loading state, versioned upgrades, blocked-deadlock handling, quota errors, key-range queries and cross-tab invalidation.",
  "registryDependencies": [
    "https://ui.zyeon.ai/r/use-broadcast-channel.json"
  ],
  "files": [
    {
      "path": "src/registry/hooks/use-indexed-db.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useBroadcastChannel } from \"@/hooks/use-broadcast-channel\"\n\n/**\n * Lifecycle of the connection. **It describes the connection only**, not whether\n * the last read or write succeeded (that is `error`): after a write that blew the\n * quota `status` is still `\"ready\"`, because the database itself is fine — only\n * that one transaction rolled back.\n *\n * - `\"opening\"` — `indexedDB.open()` is in flight. Always the value on the SSR\n *   and hydration first frame.\n * - `\"blocked\"` — this open needs a version upgrade, but **another context still\n *   holds a connection to the old version**, so the upgrade is parked\n *   indefinitely. This is the easiest way for IndexedDB to hang forever.\n * - `\"ready\"` — the connection is usable and the `storeName` object store really\n *   exists.\n * - `\"unsupported\"` — **no usable IndexedDB in this context**: the global is\n *   missing (SSR, older environments), or `open()` threw synchronously (Firefox\n *   private mode, a sandboxed iframe, site data disabled in browser settings).\n *   Terminal — it will not recover on its own.\n * - `\"error\"` — IndexedDB is there but this connection failed: the stored\n *   database is at a higher version than requested (`VersionError`), the object\n *   store is missing, the browser force-closed the connection. `error` carries a\n *   readable reason.\n */\nexport type IndexedDbStatus = \"opening\" | \"blocked\" | \"ready\" | \"unsupported\" | \"error\"\n\n/**\n * Context handed to the `upgrade` callback. **The callback must be entirely\n * synchronous** — it runs inside the `onupgradeneeded` versionchange\n * transaction, and awaiting a non-IDB promise in there lets the transaction\n * auto-commit once the microtask queue drains, after which every operation\n * throws `TransactionInactiveError`. Create stores, create indexes and migrate\n * data in place with the synchronous IDB API.\n */\nexport interface IndexedDbUpgradeContext {\n  db: IDBDatabase\n  /** The upgrade transaction. Use it to reach other object stores for migration. */\n  transaction: IDBTransaction\n  /** Version before the upgrade; 0 when the database is being created. */\n  oldVersion: number\n  /** Version after the upgrade. */\n  newVersion: number | null\n  /** Name of the object store this hook owns; it always exists before the callback runs. */\n  storeName: string\n}\n\n/**\n * Structured queries — the part localStorage cannot give you. Every field acts\n * on the **primary key**, implemented with `IDBKeyRange` plus a cursor: only the\n * matching slice is read, rather than pulling the whole store into memory to\n * filter it.\n *\n * `prefix` and `from`/`to` are mutually exclusive: pass `prefix` and the other\n * two are ignored (a prefix is already a closed range).\n */\nexport interface IndexedDbQuery {\n  /** Only records whose key starts with this (meaningful for string keys only). */\n  prefix?: string\n  /** Lower key bound, inclusive. */\n  from?: string | number\n  /** Upper key bound, inclusive. */\n  to?: string | number\n  /**\n   * How many records to read at most. `undefined` / `Infinity` = no limit; `0`\n   * or a negative number = read nothing (returns an empty array without even\n   * opening a transaction). Fractions are floored.\n   */\n  limit?: number\n  /** Cursor direction, default `\"next\"` (keys ascending). `\"prev\"` plus `limit` is \"the last N\". */\n  direction?: IDBCursorDirection\n}\n\nexport interface IndexedDbRecord<T> {\n  key: IDBValidKey\n  value: T\n}\n\n/** Result of `navigator.storage.estimate()`; all three fields are `null` when it is unavailable. */\nexport interface IndexedDbEstimate {\n  /** Bytes used by this origin (browsers fuzz this — it is not exact). */\n  usage: number | null\n  /** Quota ceiling for this origin, in bytes. */\n  quota: number | null\n  /** `usage / quota`, in `0..1`; `null` when either side is unknown. */\n  ratio: number | null\n}\n\nexport interface UseIndexedDbOptions {\n  /** Object store name, default `\"keyval\"`. Created in the upgrade transaction when missing. */\n  storeName?: string\n  /**\n   * Schema version, default `1`. **`onupgradeneeded` only fires when this number\n   * goes up** — change the index-building logic in `upgrade` without touching it\n   * and the new logic never runs for anyone who already has the database, with\n   * no error to show for it. Adding an index or changing the shape means bumping\n   * this at the same time; migration is the caller's responsibility.\n   *\n   * Non-positive, fractional and non-finite values are clamped to an integer\n   * `>= 1` (`open(name, 0)` throws a TypeError).\n   */\n  version?: number\n  /** Upgrade callback, must be synchronous. See `IndexedDbUpgradeContext`. Held in a latest-ref, so it stays out of the dependency array. */\n  upgrade?: (context: IndexedDbUpgradeContext) => void\n  /** Which slice `records` reads. A fresh object identity every render is fine: it is compared by serialised field values. */\n  query?: IndexedDbQuery\n  /**\n   * Cross-tab sync, default `true`. IndexedDB **broadcasts nothing on its own**,\n   * so every write posts a BroadcastChannel message telling same-origin tabs\n   * (and other instances on this page) to re-read. Turn it off and this instance\n   * neither sends nor receives.\n   */\n  sync?: boolean\n}\n\nexport interface UseIndexedDbResult<T> {\n  status: IndexedDbStatus\n  /** Shorthand for `status === \"ready\"`. */\n  isReady: boolean\n  /**\n   * A `records` read is in flight. **Starts as `true`**: IndexedDB is entirely\n   * asynchronous, so the first frame cannot have a value and `records` is an\n   * empty array then — rendering that first frame as \"empty\" is the classic bug.\n   */\n  isLoading: boolean\n  /**\n   * The most recent failure, from the connection or from a read/write. The next\n   * successful read clears it; connection errors are terminal and stay.\n   */\n  error: Error | null\n  /** Records matching `query`. Empty until the first read finishes (`isLoading` is `true` until then). */\n  records: IndexedDbRecord<T>[]\n  /** Read one record. Resolves to `undefined` when the key is absent; rejects on failure. */\n  get: (key: IDBValidKey) => Promise<T | undefined>\n  /**\n   * Write one record (an existing key is overwritten). Resolves on transaction\n   * `complete`, so when `await set(...)` returns the data is **actually\n   * committed**, not merely queued.\n   *\n   * Values go through structured clone: `Blob` / `File` / `ArrayBuffer` / `Map` /\n   * `Set` / `Date` and nested objects are stored as they are — exactly what\n   * localStorage, which only holds strings, cannot do. Functions, DOM nodes and\n   * class instances carrying methods reject with a diagnostic error. Crossing\n   * the quota rejects with an error that names `QuotaExceededError`, **and the\n   * transaction has rolled back, so every existing record is intact**.\n   */\n  set: (key: IDBValidKey, value: T) => Promise<void>\n  /** Delete one record. A missing key still counts as success (IDB's delete is idempotent). */\n  remove: (key: IDBValidKey) => Promise<void>\n  /** Empty the whole object store. */\n  clear: () => Promise<void>\n  /** Re-read `records` under the current `query`. Never rejects — failures land in `error`. */\n  refresh: () => Promise<void>\n  /**\n   * Read this origin's storage usage and quota. It is the only reliable signal\n   * for how much more can be written: IndexedDB gives no warning ahead of time,\n   * it throws `QuotaExceededError` at the write that crosses the line.\n   */\n  estimate: () => Promise<IndexedDbEstimate>\n}\n\n/** Cross-tab messages say \"something changed, re-read\", never the data itself — broadcasting a 50 MB Blob would be pointless. */\ninterface IndexedDbSyncMessage {\n  type: \"invalidate\"\n}\n\ninterface Connection {\n  db: IDBDatabase | null\n  promise: Promise<IDBDatabase>\n  cancelled: boolean\n}\n\n/** Upper bound of a prefix range: the highest BMP code point, written as an escape rather than a literal character. */\nconst PREFIX_UPPER_BOUND = \"\\uffff\"\n\n/** Sorts `status: \"unsupported\"` from `\"error\"` — no exported class, consumers check the name. */\nconst UNAVAILABLE = \"IndexedDbUnavailableError\"\n\nfunction errorName(cause: unknown): string {\n  return typeof cause === \"object\" && cause !== null && \"name\" in cause\n    ? String((cause as { name: unknown }).name)\n    : \"\"\n}\n\nfunction toError(cause: unknown, fallback: string): Error {\n  if (cause instanceof Error) return cause\n  return new Error(fallback)\n}\n\nfunction unavailable(message: string, cause?: unknown): Error {\n  const error = new Error(message, cause === undefined ? undefined : { cause })\n  error.name = UNAVAILABLE\n  return error\n}\n\nfunction normalizeVersion(version: number): number {\n  if (!Number.isFinite(version)) return 1\n  return Math.max(1, Math.floor(version))\n}\n\n/** `null` = no limit; `0` = read nothing. */\nfunction normalizeLimit(limit: number | undefined): number | null {\n  if (limit === undefined || !Number.isFinite(limit)) return null\n  return Math.max(0, Math.floor(limit))\n}\n\n/** A **value fingerprint** of the query: a new object identity every render does not trigger a re-read, a changed field does. */\nfunction serializeQuery(query: IndexedDbQuery | undefined): string {\n  if (!query) return \"*\"\n  return JSON.stringify([\n    query.prefix ?? null,\n    query.from ?? null,\n    query.to ?? null,\n    normalizeLimit(query.limit),\n    query.direction ?? \"next\",\n  ])\n}\n\nfunction buildRange(query: IndexedDbQuery | undefined): IDBKeyRange | null {\n  if (!query) return null\n  const { prefix, from, to } = query\n  if (prefix !== undefined && prefix !== \"\") {\n    return IDBKeyRange.bound(prefix, prefix + PREFIX_UPPER_BOUND, false, false)\n  }\n  if (from !== undefined && to !== undefined) return IDBKeyRange.bound(from, to)\n  if (from !== undefined) return IDBKeyRange.lowerBound(from)\n  if (to !== undefined) return IDBKeyRange.upperBound(to)\n  return null\n}\n\nfunction describeFailure(cause: unknown, action: string): Error {\n  switch (errorName(cause)) {\n    case \"QuotaExceededError\":\n      return new Error(\n        `useIndexedDb: ${action} exceeded this origin's storage quota (QuotaExceededError). ` +\n          \"The transaction was aborted, so nothing was written and every record already in the \" +\n          \"store is untouched. Delete records or ask the user to free space before retrying — \" +\n          \"estimate() reports usage against quota. Browsers do not silently evict a persisted \" +\n          \"origin, so this will keep failing until something is removed.\",\n        { cause },\n      )\n    case \"DataCloneError\":\n      return new Error(\n        `useIndexedDb: ${action} failed because the value is not structured-cloneable ` +\n          \"(DataCloneError). Functions, DOM nodes, class instances carrying methods and Proxies \" +\n          \"cannot be stored; plain objects, arrays, Date, Map, Set, ArrayBuffer, Blob and File can.\",\n        { cause },\n      )\n    case \"DataError\":\n      return new Error(\n        `useIndexedDb: ${action} failed because the key is not a valid IndexedDB key (DataError). ` +\n          \"Keys must be a string, a number that is not NaN, a Date, an ArrayBuffer, or an array of those.\",\n        { cause },\n      )\n    default:\n      return toError(cause, `useIndexedDb: ${action} failed.`)\n  }\n}\n\n/**\n * Collapses one transaction into a promise. **It resolves on `complete`, not on\n * `request.success`** — a successful request only means \"queued into the\n * transaction\", the commit is what makes the write real; resolving at `success`\n * swallows an abort that follows immediately after (typically a quota overrun)\n * and the caller believes the write landed.\n *\n * There is **no `await` inside the transaction**: an IDB transaction\n * auto-commits once the task/microtask queue drains, so awaiting a non-IDB\n * promise closes it early and later operations throw `TransactionInactiveError`.\n * Cursor advancing all happens in the synchronous `onsuccess`.\n */\nfunction runTransaction<R>(\n  db: IDBDatabase,\n  storeName: string,\n  mode: IDBTransactionMode,\n  action: string,\n  body: (store: IDBObjectStore, settle: (value: R) => void) => void,\n): Promise<R> {\n  return new Promise<R>((resolve, reject) => {\n    let transaction: IDBTransaction\n    try {\n      transaction = db.transaction(storeName, mode)\n    } catch (cause) {\n      reject(\n        errorName(cause) === \"NotFoundError\"\n          ? new Error(\n              `useIndexedDb: the object store \"${storeName}\" does not exist in this database. ` +\n                \"Object stores are created inside onupgradeneeded, which only runs when the \" +\n                \"version number changes — bump `version` if the store was added after the \" +\n                \"database was first created.\",\n              { cause },\n            )\n          : describeFailure(cause, action),\n      )\n      return\n    }\n\n    // Keep the request-level error: on abort some implementations leave\n    // `transaction.error` null, while the QuotaExceededError actually worth\n    // diagnosing hangs off the request that caused it.\n    let failure: unknown = null\n    let result: R = undefined as R\n\n    // Errors bubble from each request up to the transaction; catching them here\n    // also stops them surfacing as unhandled error events.\n    transaction.addEventListener(\"error\", event => {\n      const target = event.target as IDBRequest | null\n      if (failure === null && target && \"error\" in target) failure = target.error\n      if (failure === null) failure = transaction.error\n    })\n    transaction.oncomplete = () => resolve(result)\n    transaction.onabort = () => reject(describeFailure(failure ?? transaction.error, action))\n\n    try {\n      body(transaction.objectStore(storeName), value => {\n        result = value\n      })\n    } catch (cause) {\n      // put()/get() can throw synchronously (DataCloneError, DataError). The\n      // transaction is usually still alive then, so abort it rather than leave a\n      // half-finished write behind.\n      failure = cause\n      try {\n        transaction.abort()\n      } catch {\n        // The transaction may already have ended on the same error; aborting twice is pointless.\n      }\n      reject(describeFailure(cause, action))\n    }\n  })\n}\n\nfunction readRecords<T>(\n  db: IDBDatabase,\n  storeName: string,\n  query: IndexedDbQuery | undefined,\n): Promise<IndexedDbRecord<T>[]> {\n  const limit = normalizeLimit(query?.limit)\n  if (limit === 0) return Promise.resolve([])\n\n  let range: IDBKeyRange | null\n  try {\n    range = buildRange(query)\n  } catch (cause) {\n    return Promise.reject(\n      new Error(\n        \"useIndexedDb: the query bounds are not a valid key range — `from` must not be greater \" +\n          \"than `to`, and both must be valid IndexedDB keys.\",\n        { cause },\n      ),\n    )\n  }\n\n  return runTransaction<IndexedDbRecord<T>[]>(\n    db,\n    storeName,\n    \"readonly\",\n    \"reading records\",\n    (store, settle) => {\n      const out: IndexedDbRecord<T>[] = []\n      settle(out)\n      const request = store.openCursor(range, query?.direction ?? \"next\")\n      request.onsuccess = () => {\n        const cursor = request.result\n        if (!cursor) return\n        out.push({ key: cursor.key, value: cursor.value as T })\n        // At the limit, stop calling continue(): the transaction completes on its own and the rest is never read.\n        if (limit !== null && out.length >= limit) return\n        cursor.continue()\n      }\n    },\n  )\n}\n\nfunction readRecord<T>(db: IDBDatabase, storeName: string, key: IDBValidKey): Promise<T | undefined> {\n  return runTransaction<T | undefined>(\n    db,\n    storeName,\n    \"readonly\",\n    `reading key ${String(key)}`,\n    (store, settle) => {\n      const request = store.get(key)\n      request.onsuccess = () => settle(request.result as T | undefined)\n    },\n  )\n}\n\nfunction writeRecord<T>(db: IDBDatabase, storeName: string, key: IDBValidKey, value: T): Promise<void> {\n  return runTransaction<void>(db, storeName, \"readwrite\", `writing key ${String(key)}`, store => {\n    store.put(value, key)\n  })\n}\n\nfunction deleteRecord(db: IDBDatabase, storeName: string, key: IDBValidKey): Promise<void> {\n  return runTransaction<void>(db, storeName, \"readwrite\", `deleting key ${String(key)}`, store => {\n    store.delete(key)\n  })\n}\n\nfunction clearStore(db: IDBDatabase, storeName: string): Promise<void> {\n  return runTransaction<void>(db, storeName, \"readwrite\", \"clearing the store\", store => {\n    store.clear()\n  })\n}\n\n/**\n * Client-side persistence on IndexedDB: asynchronous, able to hold `Blob` /\n * `File` / large objects, quota-bound, queryable by key range. It replaces\n * `useLocalStorage` when the data does not fit, is not a string, or has to be\n * read a slice at a time — it is **not** an upgrade over it. A flag, a theme, a\n * token stays in localStorage: that is synchronous, with none of this hook's\n * empty first frame.\n *\n * Almost every IndexedDB trap is a lifecycle trap, and those are exactly what\n * this hook absorbs:\n *\n * - **Fully asynchronous, so the first frame never has a value.** `isLoading`\n *   starts `true` and `records` starts empty. On the SSR / hydration first frame\n *   `status` is always `\"opening\"` (the initial state is a constant and render\n *   reads no browser global), so there is no hydration mismatch.\n * - **`onupgradeneeded` only fires when the version changes.** Change the schema\n *   without `version + 1` and the new index-building logic silently does nothing\n *   for existing users, with no error. That part is on the caller; the hook only\n *   guarantees to create the store when it is missing and to call your `upgrade`\n *   synchronously inside the upgrade transaction.\n * - **`blocked` really does hang forever.** While another context holds a\n *   connection to the old version, the open request for the upgrade is parked\n *   indefinitely — no timeout, no error. Two defences: (1) **every** connection\n *   this hook holds listens for `versionchange`, `close()`s immediately and\n *   reconnects afterwards, so a page or app can never deadlock against itself;\n *   (2) a third-party connection outside it (an old tab the user left open)\n *   turns `status` into `\"blocked\"`, from which the UI can say \"please close the\n *   other tabs of this site\" — the upgrade resumes the moment they do. Without\n *   (1), two instances on one page asking for v1 and v2 are enough to leave the\n *   v2 one in `\"blocked\"` forever.\n * - **A quota overrun does not silently lose data.** A write counts as done on\n *   transaction `complete`, and `QuotaExceededError` is translated into a\n *   readable error before rejecting; the transaction has rolled back, so\n *   existing records are intact.\n * - **Unavailable has a definite terminal state.** A missing `indexedDB` global,\n *   or an `open()` that throws synchronously (private mode, sandboxed iframe,\n *   site data disabled) → `status: \"unsupported\"` plus a readable `error`,\n *   instead of loading forever.\n * - **No `await` inside a transaction.** All waiting happens **before** the\n *   transaction is opened, while the connection comes up.\n * - **Unmount-safe.** Late resolutions never setState; unmount detaches every\n *   handler on the connection and `close()`s it.\n * - **Cross-tab sync.** IndexedDB broadcasts nothing itself, so every write\n *   posts an `invalidate` over `useBroadcastChannel` and other tabs (plus other\n *   instances on this page) re-read under their own `query`.\n */\nexport function useIndexedDb<T = unknown>(\n  databaseName: string,\n  options: UseIndexedDbOptions = {},\n): UseIndexedDbResult<T> {\n  const { storeName = \"keyval\", query, sync = true, upgrade } = options\n  const version = normalizeVersion(options.version ?? 1)\n\n  const [state, setState] = React.useState<{\n    status: IndexedDbStatus\n    error: Error | null\n    records: IndexedDbRecord<T>[]\n    isLoading: boolean\n  }>(() => ({ status: \"opening\", error: null, records: [], isLoading: true }))\n\n  // Reconnect counter: after `versionchange` we closed the connection ourselves, so it has to be reopened as a new generation.\n  const [generation, setGeneration] = React.useState(0)\n\n  const queryRef = React.useRef(query)\n  const upgradeRef = React.useRef(upgrade)\n  const syncRef = React.useRef(sync)\n  const storeNameRef = React.useRef(storeName)\n  // Must sit before the effects below: effects run in declaration order, so the\n  // refs already hold this render's values when the connection opens and the\n  // query runs.\n  React.useEffect(() => {\n    queryRef.current = query\n    upgradeRef.current = upgrade\n    syncRef.current = sync\n    storeNameRef.current = storeName\n  })\n\n  const mountedRef = React.useRef(false)\n  const connectionRef = React.useRef<Connection | null>(null)\n  const readIdRef = React.useRef(0)\n\n  React.useEffect(() => {\n    // Set true in the effect **body**, false in cleanup. Setting it false only in\n    // cleanup leaves a live instance at false through StrictMode's\n    // mount→cleanup→mount.\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n    }\n  }, [])\n\n  const connectionKey = `${databaseName}\\u0000${storeName}\\u0000${version}\\u0000${generation}`\n  const querySignature = serializeQuery(query)\n\n  // New connection parameters (another database, store, version, or a reconnect)\n  // mean another data source, so the old records and error no longer mean\n  // anything. Adjust state during render rather than setState in an effect.\n  const [trackedKey, setTrackedKey] = React.useState(connectionKey)\n  if (trackedKey !== connectionKey) {\n    setTrackedKey(connectionKey)\n    setState({ status: \"opening\", error: null, records: [], isLoading: true })\n  }\n\n  React.useEffect(() => {\n    const connection: Connection = {\n      db: null,\n      promise: undefined as unknown as Promise<IDBDatabase>,\n      cancelled: false,\n    }\n\n    connection.promise = new Promise<IDBDatabase>((resolve, reject) => {\n      if (typeof indexedDB === \"undefined\") {\n        reject(\n          unavailable(\n            \"useIndexedDb: this context has no IndexedDB global (server render, or an environment \" +\n              \"where it is missing). Persisted data is unavailable — render a degraded UI.\",\n          ),\n        )\n        return\n      }\n\n      let request: IDBOpenDBRequest\n      try {\n        // open() really does throw synchronously: older Firefox private mode, a\n        // sandboxed iframe, and the \"block sites from saving data\" browser\n        // setting. Without catching it the whole hook sits on a loading state\n        // that never resolves.\n        request = indexedDB.open(databaseName, version)\n      } catch (cause) {\n        reject(\n          unavailable(\n            `useIndexedDb: indexedDB.open(\"${databaseName}\") threw synchronously, which means ` +\n              \"storage is disabled for this context (private browsing, a sandboxed iframe, or a \" +\n              \"browser setting that blocks site data). Fall back to in-memory state.\",\n            cause,\n          ),\n        )\n        return\n      }\n\n      request.onupgradeneeded = event => {\n        const db = request.result\n        const transaction = request.transaction\n        if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName)\n        if (!transaction) return\n        // Called synchronously. An await inside the callback commits the upgrade transaction early.\n        upgradeRef.current?.({\n          db,\n          transaction,\n          oldVersion: event.oldVersion,\n          newVersion: event.newVersion,\n          storeName,\n        })\n      }\n\n      request.onblocked = () => {\n        // Another context still holds the old-version connection, so the upgrade\n        // is parked. Deliberately **no reject**: once they close it the upgrade\n        // continues on its own and fires onsuccess. Surfacing the status is what\n        // lets the UI say \"please close the other tabs of this site\" — otherwise\n        // the user just gets a spinner that never stops.\n        if (connection.cancelled || !mountedRef.current) return\n        setState(prev => (prev.status === \"blocked\" ? prev : { ...prev, status: \"blocked\" }))\n      }\n\n      request.onsuccess = () => {\n        const db = request.result\n\n        if (!db.objectStoreNames.contains(storeName)) {\n          db.close()\n          reject(\n            new Error(\n              `useIndexedDb: the database \"${databaseName}\" is at version ${db.version} but has no ` +\n                `object store named \"${storeName}\". Another build (or another app on this origin) ` +\n                \"owns this database name. Use a different database name, or bump `version` so \" +\n                \"onupgradeneeded can create the store.\",\n            ),\n          )\n          return\n        }\n\n        // Defence (1): get out of the way the moment another context wants to\n        // upgrade (or delete) the database. Without this handler, two instances\n        // on one page asking for v1 and v2 deadlock each other — the v2 one\n        // stays blocked forever.\n        db.onversionchange = () => {\n          db.close()\n          connection.db = null\n          if (connection.cancelled || !mountedRef.current) return\n          // Reconnect as a new generation: once they finish upgrading we come\n          // back on the new schema. If they went to a higher version than ours,\n          // the reconnect ends in VersionError, translated into \"reload the page\".\n          setGeneration(g => g + 1)\n        }\n        db.onclose = () => {\n          if (connection.cancelled || !mountedRef.current) return\n          if (connectionRef.current !== connection) return\n          setState(prev => ({\n            ...prev,\n            status: \"error\",\n            isLoading: false,\n            error: new Error(\n              \"useIndexedDb: the browser closed the database connection unexpectedly (a disk \" +\n                \"error, or site data cleared while the page was open). Reload to reconnect.\",\n            ),\n          }))\n        }\n\n        if (connection.cancelled) {\n          db.onversionchange = null\n          db.onclose = null\n          db.close()\n          reject(new Error(\"useIndexedDb: the connection was closed before it became usable.\"))\n          return\n        }\n\n        connection.db = db\n        resolve(db)\n      }\n\n      request.onerror = () => {\n        const cause = request.error\n        reject(\n          errorName(cause) === \"VersionError\"\n            ? new Error(\n                `useIndexedDb: the stored database \"${databaseName}\" is at a newer version than the ` +\n                  `${version} this build asked for — another context running newer code upgraded it. ` +\n                  \"Ask the user to reload the page; code running an old schema must never downgrade it.\",\n                { cause },\n              )\n            : describeFailure(cause, `opening the database \"${databaseName}\"`),\n        )\n      }\n    })\n\n    connectionRef.current = connection\n\n    connection.promise.then(\n      () => {\n        if (connection.cancelled || !mountedRef.current) return\n        setState(prev => ({ ...prev, status: \"ready\", error: null }))\n      },\n      (cause: unknown) => {\n        if (connection.cancelled || !mountedRef.current) return\n        const error = toError(cause, \"useIndexedDb: the connection failed.\")\n        setState(prev => ({\n          ...prev,\n          status: error.name === UNAVAILABLE ? \"unsupported\" : \"error\",\n          isLoading: false,\n          error,\n        }))\n      },\n    )\n\n    return () => {\n      connection.cancelled = true\n      if (connectionRef.current === connection) connectionRef.current = null\n      const db = connection.db\n      if (db) {\n        db.onversionchange = null\n        db.onclose = null\n        db.close()\n        connection.db = null\n      }\n    }\n  }, [databaseName, storeName, version, generation])\n\n  const runQuery = React.useCallback(async () => {\n    const connection = connectionRef.current\n    if (!connection) return\n    const readId = readIdRef.current + 1\n    readIdRef.current = readId\n\n    if (mountedRef.current) {\n      setState(prev => (prev.isLoading ? prev : { ...prev, isLoading: true }))\n    }\n\n    // Last one wins: a slow older read can never overwrite a faster newer one, and anything from a replaced connection is discarded.\n    const isStale = () =>\n      !mountedRef.current || readIdRef.current !== readId || connectionRef.current !== connection\n\n    try {\n      // Waiting for the connection happens **before** the transaction opens; once it is open there is no await left.\n      const db = await connection.promise\n      const records = await readRecords<T>(db, storeNameRef.current, queryRef.current)\n      if (isStale()) return\n      setState(prev => ({ ...prev, records, isLoading: false, error: null }))\n    } catch (cause) {\n      if (isStale()) return\n      // Connection failures already set status in the connection effect, so this only fills in error and clears loading.\n      setState(prev => ({\n        ...prev,\n        isLoading: false,\n        error: toError(cause, \"useIndexedDb: reading records failed.\"),\n      }))\n    }\n  }, [])\n\n  // IndexedDB broadcasts nothing, so cross-tab sync needs its own side channel.\n  const { post } = useBroadcastChannel<IndexedDbSyncMessage>(\n    `zyeon:indexed-db:${databaseName}:${storeName}`,\n    {\n      onMessage: () => {\n        if (syncRef.current) void runQuery()\n      },\n    },\n  )\n\n  // New connection generation or a changed query → re-read. Kicking off in a\n  // microtask keeps the effect body free of synchronous setState, and under\n  // StrictMode's mount→cleanup→mount only the surviving pass actually fires.\n  React.useEffect(() => {\n    let cancelled = false\n    queueMicrotask(() => {\n      if (!cancelled) void runQuery()\n    })\n    return () => {\n      cancelled = true\n    }\n  }, [connectionKey, querySignature, runQuery])\n\n  const requireDb = React.useCallback(async () => {\n    const connection = connectionRef.current\n    if (!connection) {\n      throw new Error(\n        \"useIndexedDb: there is no connection — the hook is unmounted, or this was called during \" +\n          \"render before the connection effect ran.\",\n      )\n    }\n    return connection.promise\n  }, [])\n\n  const reportError = React.useCallback((error: Error) => {\n    if (mountedRef.current) setState(prev => ({ ...prev, error }))\n  }, [])\n\n  const afterWrite = React.useCallback(() => {\n    void runQuery()\n    // Post \"something changed\", never the data: every tab has its own query, so\n    // re-reading is the correct answer — and nobody wants a 50 MB Blob put\n    // through structured clone.\n    if (syncRef.current) post({ type: \"invalidate\" })\n  }, [post, runQuery])\n\n  const get = React.useCallback(\n    async (key: IDBValidKey) => {\n      const db = await requireDb()\n      try {\n        return await readRecord<T>(db, storeNameRef.current, key)\n      } catch (cause) {\n        const error = toError(cause, \"useIndexedDb: the read failed.\")\n        reportError(error)\n        throw error\n      }\n    },\n    [reportError, requireDb],\n  )\n\n  const set = React.useCallback(\n    async (key: IDBValidKey, value: T) => {\n      const db = await requireDb()\n      try {\n        await writeRecord(db, storeNameRef.current, key, value)\n      } catch (cause) {\n        const error = toError(cause, \"useIndexedDb: the write failed.\")\n        reportError(error)\n        throw error\n      }\n      afterWrite()\n    },\n    [afterWrite, reportError, requireDb],\n  )\n\n  const remove = React.useCallback(\n    async (key: IDBValidKey) => {\n      const db = await requireDb()\n      try {\n        await deleteRecord(db, storeNameRef.current, key)\n      } catch (cause) {\n        const error = toError(cause, \"useIndexedDb: the delete failed.\")\n        reportError(error)\n        throw error\n      }\n      afterWrite()\n    },\n    [afterWrite, reportError, requireDb],\n  )\n\n  const clear = React.useCallback(async () => {\n    const db = await requireDb()\n    try {\n      await clearStore(db, storeNameRef.current)\n    } catch (cause) {\n      const error = toError(cause, \"useIndexedDb: clearing the store failed.\")\n      reportError(error)\n      throw error\n    }\n    afterWrite()\n  }, [afterWrite, reportError, requireDb])\n\n  const refresh = React.useCallback(async () => {\n    await runQuery()\n  }, [runQuery])\n\n  const estimate = React.useCallback(async (): Promise<IndexedDbEstimate> => {\n    if (typeof navigator === \"undefined\" || typeof navigator.storage?.estimate !== \"function\") {\n      return { usage: null, quota: null, ratio: null }\n    }\n    try {\n      const { usage, quota } = await navigator.storage.estimate()\n      const ratio = usage !== undefined && quota !== undefined && quota > 0 ? usage / quota : null\n      return { usage: usage ?? null, quota: quota ?? null, ratio }\n    } catch {\n      // estimate() rejects in some isolated contexts. Unavailable is just unavailable; it must not take the UI down.\n      return { usage: null, quota: null, ratio: null }\n    }\n  }, [])\n\n  return {\n    status: state.status,\n    isReady: state.status === \"ready\",\n    isLoading: state.isLoading,\n    error: state.error,\n    records: state.records,\n    get,\n    set,\n    remove,\n    clear,\n    refresh,\n    estimate,\n  }\n}\n\nexport default useIndexedDb\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}
