Storage adapter using IndexedDB + sql.js for client-side SQL persistence.

The Key Difference: Automatic Persistence

sql.js adapter alone:

  • In-memory SQLite database (lost on page refresh)
  • You must manually call db.export() and save it yourself
  • No automatic persistence in browsers
  • Good for: Edge functions, temporary calculations, prototyping

IndexedDB adapter (this):

  • Same sql.js SQL engine, but automatically saves to IndexedDB
  • Data survives page refreshes automatically
  • Auto-save batching reduces overhead
  • Good for: Production PWAs, offline-first apps, persistent client-side storage

How It Works:

  1. IndexedDB stores the SQLite database file (binary blob) - this is just storage
  2. sql.js (WASM) loads the database into memory and executes SQL - this provides SQL capabilities
  3. After writes, the updated database is saved back to IndexedDB: on a timer (the default), before each write resolves (durability: 'commit'), or under a cross-tab lock (crossTab)

What a save keeps: each save, exportDatabase() and importDatabase() goes through sql.js's export(), which closes and reopens the connection. The adapter reads the connection's PRAGMA settings first and sets them again on the reopened connection (foreign_keys, recursive_triggers, ignore_check_constraints, legacy_alter_table, reverse_unordered_selects, trusted_schema, automatic_index, cell_size_check, secure_delete, cache_size, cache_spill, temp_store, synchronous, journal_mode, busy_timeout and query_only), so each holds as it would on a connection that is never reopened, whether open() set it (foreignKeys) or a statement did later, and registers the functions registerFunction() made again. The rest of the connection's state does not survive a save: PRAGMA settings outside that list, TEMP tables, views and triggers (the adapter warns once when a save drops some), attached databases, functions registered on the underlying sql.js database itself, and PRAGMA case_sensitive_like, which cannot be read back. Keep such state in the main database, or create it again where it is used.

Capabilities:

  • ✅ Transactions (via sql.js)
  • ✅ Persistence (via IndexedDB - automatic!)
  • ✅ Full SQL support (via sql.js)
  • ✅ Export/import
  • ✅ Several tabs on one database, with crossTab
  • ❌ Server-side (browser only)
const adapter = new IndexedDbAdapter({ dbName: 'my-app-db' });
await adapter.open();
await adapter.run('CREATE TABLE sessions (id TEXT PRIMARY KEY, data TEXT)');

Implements

Constructors

Properties

kind: "indexeddb" = 'indexeddb'

Identifier for logging/diagnostics (e.g., 'better-sqlite3', 'postgres').

capabilities: ReadonlySet<StorageCapability>

Capability flags indicating supported features.

Methods

  • Opens IndexedDB and initializes sql.js database. Loads existing database from IndexedDB if present.

    Uses a global sql.js singleton to prevent race conditions when multiple adapters (e.g., fabric_codex_db and quarry-codex-local) are initialized simultaneously. Two calls at once open once.

    Parameters

    Returns Promise<void>

  • Executes a SQL query and returns all matching rows. A statement that writes (INSERT ... RETURNING, a PRAGMA that sets a header field such as user_version) is saved like a run(); see queryWrites.

    Type Parameters

    • T

    Parameters

    • statement: string

      SELECT statement

    • Optionalparameters: StorageParameters

      Query parameters

    Returns Promise<T[]>

    Array of result rows as objects

  • Executes a callback within a database transaction.

    Callbacks run one at a time, since every transaction shares the one connection. The callback's statements go through the handle it receives; a statement issued through the adapter itself while the callback runs waits until the transaction ends, so a callback must not await one. The auto-save tick does not save while a transaction is open. With durability: 'commit' the transaction resolves once it is stored.

    Type Parameters

    • T

    Parameters

    Returns Promise<T>

  • Begins a transaction the caller ends with commit() or rollback(). It is not the callback form: other callers' statements are not held out of it. With crossTab it is refused: use transaction().

    Returns Promise<void>

  • Puts the stored image in place of the one in memory, whatever the version stamp says, and answers whether anything changed: true when another connection stored a newer image since this one loaded or saved, or when this connection holds writes that are not stored (one IndexedDB refused, which a durability: 'commit' call reports by rejecting, or with durability: 'interval' one the auto-save has not stored yet), which are then gone; false when the memory image already is the stored one, and in memory-only mode. A caller whose commit was refused runs it before its next statement: the next save, under crossTab the next coordinated statement, would store the refused writes with its own. A statement already waiting on the lock when the refusal reaches the caller runs first, so under crossTab set rollbackRefusedSaves, which gives the refused writes up before the lock is let go. Without crossTab, a durability: 'commit' write or a flush() still waiting for the save of a write it puts back rejects instead of resolving as stored. It waits for an open transaction() to end, holds statements while it runs, and refuses while a transaction begun with BEGIN is open, which replacing the database would end.

    Returns Promise<boolean>

  • Stores every write made so far in IndexedDB. A transaction() that is open is waited for and stored with the rest; a transaction a caller began with BEGIN cannot be stored without ending it, so flush() rejects while one is open. Resolves at once when nothing is waiting to be saved, and rejects when reload() puts the stored image in place of writes it was waiting to store.

    Returns Promise<void>

  • Closes the database and saves its committed state to IndexedDB.

    A transaction still open is rolled back, as closing a SQLite connection does, before the save; the save then holds committed writes only. A call made while a close is running waits for that close.

    Returns Promise<void>

  • Exports the database as a Uint8Array (SQLite file format). Can be downloaded or stored externally. With crossTab it is this tab's image, which another tab's save can have overtaken.

    Throws while a transaction is open: sql.js's export closes and reopens the connection, which would end the transaction.

    Returns Uint8Array

    SQLite database file as binary

  • Imports a database from a Uint8Array (SQLite file).

    The image is checked before anything is replaced: it must start with the SQLite header (an empty image is an empty database) and pass SQLite's quick_check. sql.js's new Database(bytes) takes any bytes, since SQLite reads the file only when a statement needs it, and a damaged page is read only by a statement that reaches it, so without the check a file of another kind or a damaged image would replace the database and be saved over the stored one. A refused image leaves the adapter on its database, in memory and in IndexedDB, and the call throws.

    The imported database replaces the current one once any open transaction() has ended and a save already running has finished, takes the connection's PRAGMA settings, and is saved to IndexedDB before the call resolves; with crossTab, in place of what every tab stored.

    Parameters

    • data: Uint8Array

      SQLite database file

    Returns Promise<void>