@framers/sql-storage-adapter
v0.8.0
Published
Robust cross-platform SQL storage abstraction with automatic fallbacks and runtime detection
Readme
SQL Storage Adapter
Cross-platform SQL access for Node.js, web, and native runtimes with automatic adapter selection and consistent APIs.
The SQL Storage Adapter provides a single, ergonomic interface over SQLite (native and WASM), PostgreSQL, Capacitor, IndexedDB, and in-memory stores. It handles adapter discovery, capability detection, and advanced features like cloud backups so you can focus on your application logic.
🆕 NEW in v0.6.0: SQL Dialect abstractions for SQLite/Postgres parity + Cross-platform BLOB codec + Full-text search interface!
- Features
- Installation
- Quick Start
- Adapter Matrix
- Electron Adapter
- Cross-Platform Sync
- Mutation Sync
- Configuration & Resolution
- Segments
- A PostgreSQL Pool of Your Own
- Key-Value Store
- Platform Strategy
- SQL Dialect & Feature Abstractions
- CI, Releases, and Badges
- Contributing
- License
Features
- Auto-detected adapters –
createDatabase()inspects environment signals and picks the best backend (native SQLite, PostgreSQL, Capacitor, sql.js, IndexedDB, memory, etc.). - Capability-aware API – consistent CRUD, transactions, batching, and event hooks across adapters with runtime capability introspection.
- 🆕 IndexedDB – sql.js + IndexedDB persistence wrapper for browser-native, offline-first web apps (uses sql.js for SQL execution, IndexedDB for storage).
- 🆕 Electron Adapter – Full IPC bridge with main/renderer process split, WAL management, auto-migrations, multi-window support.
- 🆕 Cross-Platform Sync – Real-time delta sync with vector clocks, WebSocket/HTTP transports, conflict resolution UI hooks, and device registry.
- 🆕 Performance Tiers – Configurable
fast,balanced,accurate,efficientpresets for cost/accuracy tradeoffs. See Optimization Guide. - 🆕 Lifecycle Hooks – Extensible hooks (
onBeforeQuery,onAfterQuery,onBeforeWrite,onAfterWrite) for logging, analytics, caching, and custom extensions. - Cloud backups & migrations – built-in backup manager with compression, retention policies, and restore helpers plus migration utilities.
- Portable packaging – optional native dependencies; falls back to pure TypeScript/WASM adapters when native modules are unavailable.
- Browser-friendly – Dynamic imports prevent bundlers from including server-only dependencies (
pg,path) in browser builds. - Mobile/Offline Parity – Same APIs work across desktop, mobile (Capacitor), and browser with automatic sync support.
- 🆕 SQL Dialect – Write cross-platform SQL with
SqlDialectinterface. Automatically translatesINSERT OR IGNORE,json_extract,ifnull,PRAGMAbetween SQLite and PostgreSQL. - 🆕 Full-Text Search –
IFullTextSearchinterface abstracts FTS5 (SQLite) and tsvector/GIN (PostgreSQL) with unifiedcreateIndex,matchClause,rankExpression, andrebuildCommandAPIs. - 🆕 BLOB Codec –
IBlobCodecfor cross-platform binary vector storage.NodeBlobCodec(Buffer) for server,BrowserBlobCodec(DataView) for web. - 🆕 Database Export –
IDatabaseExporterwithSqliteFileExporter(VACUUM INTO) andPostgresExporter(pg_dump). - CI-first design – Vitest coverage, Codecov integration, and GitHub Actions workflows for linting, testing, releasing, and npm publish/tag automation.
Installation
# Core package: brings pg (PostgreSQL) and sql.js (WASM SQLite, and IndexedDB)
npm install @framers/sql-storage-adapter
# Install only the other engines you plan to use
npm install better-sqlite3 # Native SQLite for Node/Electron
npm install @capacitor-community/sqlite # Capacitor / mobilepg and sql.js are dependencies of the package, listed once each, so they install with it. better-sqlite3, @capacitor-community/sqlite, electron and @aws-sdk/client-s3 are optional peers: install the ones you use.
Windows users: ensure the Visual Studio Build Tools (C++ workload) are installed before adding
better-sqlite3. On Linux, installpython3,build-essential, andlibssl-devprior tonpm install.
Note: If
better-sqlite3cannot be required, install native build tools beforenpm install, ensure your Node version matches available prebuilt binaries, or fall back tosql.jsorindexeddbby settingSTORAGE_ADAPTER=sqljsorSTORAGE_ADAPTER=indexeddb.
better-sqlite313 needs Node-API 10: Node.js 22.14+ or 23.6+, or Electron 35+ (whose Node is 22.14). On an older runtime (Node.js 20, Node.js 22.13 and earlier, Electron 34 and earlier) installbetter-sqlite3@12; with 13 installed there, the adapter refuses to open, so the resolver falls back tosql.js, rather than letting the native binding crash the process.better-sqlite313 ships prebuilt binaries, but npm can still runnode-gyp rebuildfor it, which needs the build tools above (Python, and make and a C++ compiler, or the Visual Studio Build Tools on Windows): always when it installs from a lockfile (npm ci, ornpm installwith apackage-lock.json), and on some platforms on a fresh install too (WiseLibs/better-sqlite3#1516).
Entries
The root entry exports everything and imports every adapter. A server takes its adapter from that adapter's own entry, with the rest from ./core, so its bundle carries neither sql.js nor the S3 client:
import { createPostgresAdapter } from '@framers/sql-storage-adapter/postgres';
import { createStorageFeatures, runSchemaMigrations, isUniqueViolation } from '@framers/sql-storage-adapter/core';| Entry | Holds |
| --- | --- |
| @framers/sql-storage-adapter | Everything, with createDatabase and the resolver |
| ./core | Contracts and errors, dialects, full-text search, codecs, exporters, createStorageFeatures, the schema-migration runner, isUniqueViolation, the logger. No adapter |
| ./postgres, ./better-sqlite3, ./sqljs, ./indexeddb, ./capacitor | One adapter each |
| SupabaseAdapter (root) | Deprecated: use ./postgres with the Supabase connection string |
| ./backup | S3-compatible backups (@aws-sdk/client-s3) |
| ./authjs | The Auth.js database adapter (also in ./core) |
| ./electron, ./electron/renderer, ./electron/preload | The Electron adapters. ./electron/renderer holds only what a window uses; require('./electron/preload') gives one bundled CommonJS file for a sandboxed preload |
| ./sync, ./kv, ./types | Cross-platform sync and the mutation sync, the key-value store, the types |
| ./browser | What a page needs, in one entry: the IndexedDB adapter, the key-value module with its migration, the tab lock, and the mutation sync's client with its wire contract, transport, loop and change channel. Its modules import no Node built-in and no pg: the one package they import is sql.js |
Every entry has an import and a require condition. The CommonJS build under dist/cjs is made from the ES module build file by file, so a class has one identity whichever entry a caller takes it from. It carries its own declarations, so a CommonJS TypeScript consumer under node16 resolution type-checks against them. CI packs the package, installs it in a new project and checks each of these: require() of every entry, what each server entry and ./browser bundle, and the preload.
Quick Start
import { createDatabase } from '@framers/sql-storage-adapter';
async function main() {
// Automatically selects the best adapter for the current runtime
const db = await createDatabase();
await db.exec(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY,
task TEXT NOT NULL,
done INTEGER DEFAULT 0
)
`);
await db.run('INSERT INTO todos (task) VALUES (?)', ['Ship cross-platform builds']);
const items = await db.all<{ id: number; task: string; done: number }>('SELECT * FROM todos');
console.log(items);
await db.close();
}
main().catch((error) => {
console.error('Database bootstrap failed', error);
process.exit(1);
});Platform-Specific Examples
import { createDatabase, IndexedDbAdapter } from '@framers/sql-storage-adapter';
// Web (Browser): Uses IndexedDB
const webDb = await createDatabase({ priority: ['indexeddb', 'sqljs'] });
// Desktop (Electron): Uses better-sqlite3
const desktopDb = await createDatabase({ priority: ['better-sqlite3', 'sqljs'] });
// Mobile (Capacitor): Uses native SQLite
const mobileDb = await createDatabase({ priority: ['capacitor', 'indexeddb'] });
// Cloud (Node): Uses PostgreSQL. strict fails when DATABASE_URL is unset,
// where it would otherwise open a local SQLite file.
const cloudDb = await createDatabase({
url: process.env.DATABASE_URL,
strict: true
});See Platform Strategy Guide for detailed pros/cons and architecture.
Adapter Matrix
| Adapter | Package | Ideal for | Pros | Considerations |
| --- | --- | --- | --- | --- |
| 🆕 electron | bundled | Electron desktop apps | IPC bridge, multi-window, WAL, auto-migrations, crash recovery | Requires Electron runtime; create it from @framers/sql-storage-adapter/electron (the resolver does not build the electron-main and electron-renderer kinds) |
| 🆕 indexeddb | bundled (sql.js) | Browsers, PWAs | sql.js + IndexedDB persistence wrapper, browser-native storage, 50MB-1GB+ quota, offline-first | IndexedDB quotas vary, WASM overhead (sql.js), not a separate SQL engine |
| better-sqlite3 | better-sqlite3 | Node/Electron, CLI, CI | Native performance, transactional semantics, WAL support | Needs native toolchain; version must match Node ABI; 13.x needs Node-API 10 (Node.js 22.14+, Electron 35+) |
| postgres | pg | Hosted or on-prem PostgreSQL | Connection pooling, rich SQL features, cloud friendly | Requires DATABASE_URL/credentials |
| sqljs | bundled | Browsers, serverless edge, native fallback | Pure WASM SQLite, no native deps, optional filesystem persistence | Write performance limited vs native, in-memory by default |
| capacitor | @capacitor-community/sqlite | Mobile (iOS/Android, Capacitor) | Native SQLite on mobile, encryption | Requires Capacitor runtime |
| memory | bundled (sql.js) | Unit tests, storybooks, constrained sandboxes | Zero dependencies, instant startup | The resolver builds the sql.js adapter for it, as for sqljs: in memory in a browser, persisted to the resolver's file path on Node |
Platform Priorities
| Platform | Primary Adapter | Fallback | Use Case |
|----------|----------------|----------|----------|
| Web (Browser) | IndexedDB | sql.js | PWAs, offline-first web apps |
| Electron (Desktop) | electron | better-sqlite3 | Desktop apps, dev tools |
| Capacitor (Mobile) | capacitor | IndexedDB | iOS/Android native apps |
| Node.js | better-sqlite3 | Postgres, sql.js | CLI tools, local servers |
| Cloud (Serverless) | Postgres | None with strict or createDatabase({ url }); better-sqlite3 from resolveStorageAdapter() otherwise | Multi-tenant SaaS, APIs |
Electron Adapter
The Electron adapter provides a complete IPC bridge architecture for Electron apps with main/renderer process split.
// Main process (main.ts)
import { createElectronMainAdapter } from '@framers/sql-storage-adapter/electron';
const db = await createElectronMainAdapter({
filePath: path.join(app.getPath('userData'), 'app.db'),
wal: { enabled: true, checkpointInterval: 30000 },
autoMigration: { enabled: true, migrationsPath: './migrations' },
multiWindow: { enabled: true, broadcastChanges: true },
});
await db.open();// Renderer process
import { createElectronRendererAdapter } from '@framers/sql-storage-adapter/electron';
const db = createElectronRendererAdapter();
await db.open();
const users = await db.all('SELECT * FROM users');Features:
- ✅ Type-safe IPC protocol with request/response correlation
- ✅ WAL checkpoint management and corruption detection
- ✅ Auto-migration on app version change
- ✅ Multi-window database change broadcasting
- ✅ Preload script with secure
contextBridgeAPI
Cross-Platform Sync
Real-time delta synchronization across Electron, Capacitor, browser, and server platforms.
import { createCrossPlatformSync } from '@framers/sql-storage-adapter/sync';
const sync = await createCrossPlatformSync({
localAdapter: db,
endpoint: 'wss://sync.example.com',
authToken: 'bearer-token',
device: { name: 'MacBook Pro', type: 'electron' },
tables: {
notes: { priority: 'high', conflictStrategy: 'merge' },
settings: { priority: 'critical', conflictStrategy: 'local-wins' },
},
hooks: {
onConflictNeedsResolution: async (conflict) => {
// Show UI for manual conflict resolution
return showConflictDialog(conflict);
},
onSyncComplete: (result) => {
console.log(`Synced: ${result.changesPushed} pushed, ${result.changesPulled} pulled`);
},
},
});
// Manual sync
await sync.sync();
// Or enable real-time sync
await sync.connect();Features:
- ✅ Vector Clocks – Distributed causality tracking for accurate conflict detection
- ✅ WebSocket Transport – Real-time bidirectional sync with auto-reconnection
- ✅ HTTP Fallback – Polling transport for firewalls that block WebSocket
- ✅ Conflict Resolution – Strategies:
last-write-wins,local-wins,remote-wins,merge,manual - ✅ Device Registry – Track syncing devices with presence status (online/offline/syncing)
- ✅ UI Hooks – Custom conflict resolution dialogs
Conflict Strategies:
| Strategy | Description |
|----------|-------------|
| last-write-wins | Most recent change wins (by timestamp) |
| local-wins | Local changes always take priority |
| remote-wins | Remote changes always take priority |
| merge | Field-level merge with custom mergers |
| manual | Defer to UI hook for user decision |
Mutation Sync
@framers/sql-storage-adapter/sync also holds a mutation sync for named text rows that a server and many browsers keep the same. A browser saves its rows with the mutations that make them in one transaction of its own database and never waits on the network. The server runs every client's mutations in one order, each push in one transaction that records the client's last mutation id, so a push sent again applies nothing twice, and settles each conflict by that order, keeping a differing value as a copy whose id every side derives alike. A pull streams the server's changes from one snapshot, and the browser replays what the server has not answered over them with the same functions the server runs. The protocol also recovers a browser after a restore of the server's database, keeps a value the server cannot read in the browser that has it, and checks that the server holds every row before a browser removes its own. The server's half (MutationServer over your own tables or the reference createSqlMutationStore, and DrainGate) is in ./sync; the client's half (MutationClient, createFetchMutationTransport, SyncLoop and createTabLock) is in ./sync and ./browser. See guides/MUTATION_SYNC.md for both halves, with an example server, its routes and a page.
Configuration & Resolution
resolveStorageAdapterinspects:- explicit options (
priority,type, adapter configs), - environment variables (
STORAGE_ADAPTER,DATABASE_URL), - runtime hints (Capacitor detection, browser globals, IndexedDB availability).
- explicit options (
- Adapters are attempted in priority order until one opens successfully; a
StorageResolutionErrorincludes the full failure chain. - The resolver builds
postgres,better-sqlite3,capacitor,indexeddbandsqljs, and builds sql.js formemory. Any other kind (electron-main,electron-renderer,supabase, or an unknown name, frompriorityorSTORAGE_ADAPTER) fails as a candidate with an error that names it, and the next candidate is tried. - On Node,
createDatabase({ url }),createDatabase({ postgres })andconnectDatabase()open PostgreSQL, with every Postgres option (pool size, SSL, timeouts, application name, or host, port, database, user and password without a connection string, or apoolof your own) passed on, and do not fall back to SQLite.DATABASE_URLin the environment does not change whatcreateDatabase()picks;resolveStorageAdapter()without aprioritytries Postgres first when it is set. - In a browser,
createDatabase()keeps the database in IndexedDB (['indexeddb', 'sqljs'], sql.js in memory where IndexedDB cannot open), whatever the Postgres options say;createDatabase({ file: ':memory:' })andcreateMemoryDatabase()keep sql.js in memory. In a Capacitor app on a phone it chooses['capacitor', 'indexeddb', 'sqljs'], as the resolver does. On Deno it opens sql.js.indexedDboptions (dbName,durability,crossTaband the rest) reach the IndexedDB adapter whole. strict: true(oncreateDatabaseandresolveStorageAdapter) is for servers: only the database the options name opens. A postgres candidate that fails ends resolution with aStorageResolutionError, whateverprioritylists after it; withDATABASE_URLor Postgres options that name a server and nopriority,['postgres']is the whole list; an explicit file path (file,filePath) outranksDATABASE_URL; and aurlorpostgresoption that names no server fails instead of opening the local default, unless a file is given. Withoutstrict,resolveStorageAdapter()falls back from a failed Postgres to better-sqlite3, IndexedDB and sql.js, andDATABASE_URLcomes before afilePath.foreignKeys(oncreateDatabase, or inopenOptionsfor the resolver andopen()) sets SQLite's foreign-key enforcement when a SQLite adapter opens:trueturns it on andfalseoff, on better-sqlite3, sql.js, IndexedDB and Capacitor. The setting holds across the sql.js saves, exports and imports, which reopen the connection and reset its PRAGMAs. Left out, each engine keeps its default, and the defaults differ: better-sqlite3 enforces foreign keys (it is built withSQLITE_DEFAULT_FOREIGN_KEYS=1, per its compilation options), while sql.js, IndexedDB and Capacitor do not, as SQLite leaves foreign keys off by default. Until a product passesforeignKeys, the same schema behaves differently per engine. PassforeignKeys: truefor the same behaviour everywhere. PostgreSQL always enforces foreign keys and ignores the option.- Provide
priority: ['indexeddb', 'sqljs']for browser bundles or tests where native modules shouldn't load. - Use
createCloudBackupManagerfor S3-compatible backups with gzip compression and retention limits.
Transactions
transaction(fn) commits when fn resolves and rolls back when it throws. Run the callback's statements through the handle it receives:
await db.transaction(async (trx) => {
await trx.run('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 'a']);
await trx.run('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 'b']);
});The IndexedDB, Capacitor and better-sqlite3 adapters run every transaction on one connection, so they run transactions one at a time, and a statement from another caller waits until the open transaction ends: it neither joins the transaction (and vanishes in its rollback) nor reads what the transaction has not committed. A browser or a WebView has no async context to tell the callback's own statements apart, so on the IndexedDB and Capacitor adapters a statement the callback issues through the adapter itself, not through its handle, waits for the transaction too. The two then wait on each other, and the adapter logs a warning after 10 seconds. (On Node, better-sqlite3 tells them apart with AsyncLocalStorage.) Inside a callback, run every statement through the handle, and do not await work that itself needs the database.
The PostgreSQL adapter runs each transaction on its own pooled connection, so transactions run side by side. When the connection breaks during a transaction and its ROLLBACK fails too, transaction() still throws the transaction's own error (the callback's, or COMMIT's), with the ROLLBACK error on it as a non-enumerable rollbackError property. An error aborts a PostgreSQL transaction, and PostgreSQL answers the COMMIT of an aborted transaction with ROLLBACK and no error of its own (utility.c), so when a statement in the callback fails and the callback catches its error and goes on, nothing of the transaction is written, and transaction() rejects. A statement that may fail inside a transaction runs under a savepoint of its own, as batch() runs each operation. The broken connection is closed, not returned to the pool. A connection the server closes while a callback holds it, between statements, does not end the process. Once a transaction has ended, its handle runs on the pool, as the adapter's own statements do, and its transaction() begins a new transaction.
Batches
batch(operations) runs its operations in one transaction and goes on past one that fails, on better-sqlite3 and PostgreSQL (capabilities.has('batch')):
const result = await db.batch([
{ statement: 'INSERT INTO tags (id, name) VALUES (?, ?)', parameters: [1, 'red'] },
{ statement: 'INSERT INTO tags (id, name) VALUES (?, ?)', parameters: [1, 'red again'] }, // the id is taken
{ statement: 'INSERT INTO tags (id, name) VALUES (?, ?)', parameters: [2, 'blue'] },
]);
// { successful: 2, failed: 1, results: [...], errors: [{ index: 1, error }] }- The operations that succeed commit together.
- An error aborts a PostgreSQL transaction until it rolls back to a savepoint (Transactions), so the PostgreSQL adapter runs each operation under a savepoint and rolls back to it when the operation fails (ROLLBACK TO SAVEPOINT).
- When the transaction itself fails (its BEGIN or COMMIT), nothing commits, and the result reports every operation failed, with the error at index -1.
- A transaction handle's
batch()runs in that transaction, and its operations commit or roll back with it. On a held PostgreSQL connection (withConnection),batch()runs in a transaction of its own.
SQL functions
registerFunction(name, fn, { deterministic }) on the better-sqlite3, sql.js and IndexedDB adapters makes a JavaScript function callable from SQL:
db.registerFunction('slugify', (title) => String(title).toLowerCase().replace(/[^a-z0-9]+/g, '-'), { deterministic: true });
const rows = await db.all('SELECT slugify(title) AS slug FROM posts');- The name must be an identifier: letters, digits and underscores.
- The function takes as many arguments as it declares (
fn.length), on both engines, so a rest parameter declares none (better-sqlite3, sql.js). - sql.js drops its functions when
export()closes and reopens the connection (source): a save to a file, an IndexedDB save,exportDatabase()andimportDatabase(). The sql.js and IndexedDB adapters register them again after each reopening. deterministic: truetells better-sqlite3 that the function returns the same result for the same arguments, which SQLite requires of a function in an expression index, a partial index's WHERE clause or a generated column (SQLite). sql.js registers every function as non-deterministic.- The better-sqlite3 adapter must be open; the sql.js and IndexedDB adapters keep a function registered before
open()and register it when they open.
Segments
A segment keeps one user's tables apart from another's in a database they share: AgentOS's tables beside an app's, in one PostgreSQL database or one browser.
import { createDatabase } from '@framers/sql-storage-adapter';
// The app's own tables, and AgentOS's in a segment of the same database
const app = await createDatabase({ url: process.env.DATABASE_URL });
const agentos = await createDatabase({ url: process.env.DATABASE_URL, segment: 'agentos' });| Backend | What a segment is | segment: 'agentos' |
| --- | --- | --- |
| PostgreSQL | A schema of its own | Every connection of the pool starts with search_path set to agentos, public; open() creates the schema when it is missing |
| better-sqlite3, sql.js, Electron | A file of its own | app.sqlite3 becomes app-agentos.sqlite3; :memory: stays as it is |
| IndexedDB | A database of its own | app-db becomes app-db-agentos |
| Capacitor | A database of its own | app becomes app-agentos |
A segment name is lower-case letters, digits and underscores, starts with a letter or an underscore, and has at most 63 characters; it is not public, information_schema or a name beginning with pg_. A name outside that is refused before any adapter is tried, whether it is given to createDatabase() or resolveStorageAdapter() or in their postgres, indexedDb or capacitor options. segment goes on createDatabase(), resolveStorageAdapter() and each adapter's own options, and segmentName() and segmentFilePath() give the name a segment's database or file has.
A segment on createDatabase() or resolveStorageAdapter() applies to every adapter they try. A segment in the options of one adapter (postgres: { segment }) applies to that adapter, and to each adapter tried after it whose options name none: without strict, a PostgreSQL that cannot be reached falls through to a local file in the same segment (app-agentos.sqlite3), never to the file every user shares. An adapter tried before it is left as it is.
On PostgreSQL:
- The
search_pathis a startup option of each connection, so a connection the pool opens to replace one the server ended starts in the segment too, andRESET search_pathandDISCARD ALLreturn to it.PGOPTIONSis kept beside it. A connection string that sets its ownoptionsparameter is refused with a segment, since pg would send it in place of the segment's. publicstays on the path, so the types, functions and operators of extensions installed there resolve in every segment: pgvector'svectorand<->. ACREATE EXTENSIONthat names no schema installs the extension intopublic, as it would without a segment, instead of into the segment, where no other segment and not the host would find it. When an extension a segment asks for is already installed in another segment's schema, the adapter warns once and names theALTER EXTENSION ... SET SCHEMA publicthat moves it.- A name the segment does not hold falls through to
public. Create a segment's tables before reading them (CREATE TABLE IF NOT EXISTScreates the table in the segment even whenpublichas one of that name), and name the schema in aDROPorALTERthatpubliccould also answer. open()checks that the segment is the current schema of its first connection, and fails when it is not. PostgreSQL leaves a schema the role has noUSAGEprivilege on out of thesearch_pathwithout a word, so on an existing schema another role made, the segment's statements would otherwise read and create tables inpublic; a pooler that drops startup options leaves the segment out the same way. The role needsUSAGEandCREATEon the segment's schema.exportData()exports the tables of the current schema: the segment's, orpublicwithout one.createStorageFeatures(adapter).exporterdumps the segment's schema alone (pg_dump --schema).- A segment cannot run on a pool of your own: the adapter sets
search_pathwhen it starts a connection, and it does not start the connections of a pool it did not make.createDatabase()andresolveStorageAdapter()refuse a segment withpostgres.poolbefore they try any adapter.
On the Electron main adapter, a segmented adapter serves the main process alone: the renderer's IPC channels are one set per process, and they stay with the adapter without a segment.
A PostgreSQL Pool of Your Own
The Postgres adapter runs on a pg Pool you made, from the start, with no open() needed:
import pg from 'pg';
import { PostgresAdapter } from '@framers/sql-storage-adapter';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const db = new PostgresAdapter({ pool, onError: (error) => console.warn('idle connection failed', error) });
await db.close(); // leaves the pool open; pool.end() is yoursclose()leaves a pool of yours open. PassownsPool: truewhen the adapter is the pool's one holder and closing the adapter is how the pool ends:close()then ends it once, however often it is called.open()afterclose()takes the pool up again, unless it has ended.onErroris called with the error an idle connection of the pool reports (the server ended it, the network dropped). pg raises such an error as an event that ends the process when nothing listens. On a pool of yours the adapter listens only when givenonError, and takes its listener off onclose(); on a pool it makes or owns it listens anyway, and logs a warning when noonErrorwas given.getPool()returns the pool the adapter runs on: yours, or the one it made, while it is open.- The settings that configure a pool when one is made (
connectionString,host,max,ssl, the timeouts,typeParsersand the rest) andsegmentcannot be given withpool: set them on the pool (typeParsersas the pool's owntypes). createDatabase({ postgres: { pool } })opens PostgreSQL on it.
A host on 0.6 that wrote its Pool into the adapter's pool field before open() keeps working: the adapter runs on that Pool and ends it on close(), as 0.6 did. Moving to { pool }, such a host passes ownsPool: true to keep close() ending the Pool.
Placeholders
Bind ? placeholders from an array, @name placeholders from an object, or $1, $2, ... from an array on PostgreSQL and SQLite alike:
await db.run('INSERT INTO notes (id, body) VALUES (?, ?)', [1, 'Is this saved?']);
await db.get('SELECT * FROM notes WHERE id = @id', { id: 1 });
await db.all('SELECT * FROM notes WHERE id = $1 OR parent_id = $1', [1]);- A
?,@nameor$1inside a string literal, a quoted identifier, a PostgreSQL dollar-quoted string or a comment is text, never a placeholder. - On PostgreSQL,
??is the jsonb?(key exists) operator, and?|and?&stay operators; put a space between a placeholder and a following|or&. A statement that already uses$nis sent as written, so its?operators need no escape, and a statement run without parameters keeps its?. - On SQLite,
$1,$2, ... become?with the values in their order, repeats included. A statement that mixes?and$nis refused.
Schema migrations
runSchemaMigrations() applies hand-written SQL files in number order, on PostgreSQL and SQLite alike:
- each file runs in its own transaction and is recorded in a ledger table with the SHA-256 of its text;
- a file already in the ledger is skipped;
- an applied file whose text changed is refused, as is a ledger row the list lacks (the database is ahead of the code);
- a failing file is rolled back, and the error names it.
import { readSchemaMigrations, runSchemaMigrations, schemaMigrationStatus } from '@framers/sql-storage-adapter';
// migrations/0001_accounts.sql, migrations/0002_billing.sql, ...; or migrations/postgres/ and migrations/sqlite/ per dialect
const migrations = await readSchemaMigrations('migrations', { dialect: 'postgres' });
const report = await runSchemaMigrations(db, migrations); // { applied, skipped, ... }
const status = await schemaMigrationStatus(db, migrations); // { applied, pending, changed, unknown }; applies nothing- Files. File names are digits, an underscore and a lower-case name (
001_init.sql,0001_accounts.sql). They are ordered by number, and a number used twice is refused.readSchemaMigrations(dir, { dialect })readsdir/postgresordir/sqlitewhen that folder exists, elsedir. A file holds noBEGINorCOMMIT. - Ledger.
table(defaultschema_migrations) andchecksumColumn(defaultchecksum) name the ledger. An existing ledger withnameand a SHA-256 column is read as it is, for example{ table: '_migrations', checksumColumn: 'content_hash' }. - Locking. On PostgreSQL each file's transaction takes
pg_advisory_xact_lock(lockKey)and checks the ledger again, so two runners at once apply each file once.lockKeyis an integer, or text hashed withhashtext(). On SQLite the file's write transaction is the lock. - Outside a transaction. A file whose first line is
-- outside transaction(outsideTransactionMarker) runs outside a transaction, forCREATE INDEX CONCURRENTLYorVACUUM. On PostgreSQL it runs under the session lock on one held connection (withConnection). - Unknown rows.
unknown: 'ignore'lets a run go on past ledger rows the list lacks.
ensureColumnExists(db, table, column, definition) adds a column when the table lacks it, on PostgreSQL and SQLite, and resolves true when it added the column, false when the table had it:
import { ensureColumnExists } from '@framers/sql-storage-adapter';
await ensureColumnExists(db, 'notes', 'archived_at', 'TEXT');
await ensureColumnExists(db, 'app.notes', 'pinned', 'BOOLEAN NOT NULL DEFAULT false'); // schema-qualified, PostgreSQL onlyThe table and column must be identifiers (letters, digits and underscores); the definition is SQL, written for the engine. PostgreSQL reads information_schema.columns and adds the column with ADD COLUMN IF NOT EXISTS; SQLite reads pragma_table_info. Names are matched as PostgreSQL folds unquoted names, in lower case.
Copying data between engines
migrateAdapter(source, target, options) copies tables and rows from one adapter to another, on one engine or between SQLite and PostgreSQL. exportData() and importData() are its two halves, and migrateAdapters(), cloneAdapter() and createBackup() run on them.
import { migrateAdapter } from '@framers/sql-storage-adapter';
const sqlite = await createDatabase({ file: './app.db' });
const postgres = await createDatabase({ url: process.env.DATABASE_URL });
const result = await migrateAdapter(sqlite, postgres);
// { success, tablesImported, rowsImported, errors?, warnings?, duration }What the target table gets:
| Source column | PostgreSQL target | SQLite target |
|---|---|---|
| SQLite INTEGER PRIMARY KEY, PostgreSQL identity or serial | INTEGER GENERATED BY DEFAULT AS IDENTITY (or BIGINT when copied ids exceed INTEGER) (a PostgreSQL identity keeps its type and ALWAYS) | INTEGER PRIMARY KEY AUTOINCREMENT |
| integers | INTEGER, or BIGINT when a copied value is outside INTEGER's range | INTEGER |
| PostgreSQL NUMERIC, DECIMAL | exact source type | TEXT to preserve decimal precision |
| REAL, DOUBLE, FLOAT | DOUBLE PRECISION | REAL |
| BOOLEAN | BOOLEAN | INTEGER |
| DATETIME, TIMESTAMP | TIMESTAMPTZ | TEXT |
| DATE, TIME | DATE, TIME | TEXT |
| JSON, arrays | JSONB | TEXT |
| BLOB | BYTEA | BLOB |
| text, UUID, others | TEXT, UUID, NUMERIC by SQLite's affinity rules | TEXT |
- SQLite foreign keys. Imports suspend enforcement while loading, report
foreign_key_checkviolations, and restore the original setting even on failure. Import outside an active transaction so SQLite can change enforcement. - Same engine. SQLite to SQLite runs the
CREATE TABLEstatement SQLite keeps for the table, so its keys,AUTOINCREMENT, constraints and generated columns come along. SQLite copies and SQL dumps also carry the AUTOINCREMENT high-water mark, including deleted ids and empty tables. PostgreSQL to PostgreSQL keeps each type asformat_typenames it (character varying(20),numeric(10,2),integer[]), and regenerates stored and PostgreSQL 18 virtual columns from their expressions. Virtual definitions require PostgreSQL 18 or later on the target. - Keys and NOT NULL. The primary key and NOT NULL carry over between engines, including exports by a PostgreSQL role with only SELECT permission. Other constraints and indexes do not; create them with your schema migrations.
- Array and numeric defaults. PostgreSQL empty-array defaults become JSON
[]on SQLite; other array defaults are omitted with a warning. PostgreSQL NUMERIC values and literal defaults become text so SQLite numeric affinity cannot round them. SQLitestrftime('%S','now')keeps seconds within the minute;%skeeps epoch seconds. - Defaults. Compatible literals carry over;
trueandfalsebecome 1 and 0 on SQLite and for numeric PostgreSQL columns. Invalid or unrecognized SQLite calendar defaults are omitted with a warning, and the current-time defaults map to the target engine's (CURRENT_TIMESTAMPanddatetime('now')toCURRENT_TIMESTAMPon PostgreSQL;now()to the ISOstrftimeon SQLite). A default with no form on the target engine is left off, andwarningsnames it. - Ids. Explicit id columns keep their values. SQLite FTS rowids are included so external-content indexes keep pointing to the same rows. Implicit rowids of ordinary tables are not preserved; use an explicit INTEGER PRIMARY KEY for stable references. On PostgreSQL each identity or serial column that took ids has its sequence moved past the largest one without lowering its existing allocation (setval). A target table that already exists is written as it is, and an identity
GENERATED ALWAYStakes the ids throughOVERRIDING SYSTEM VALUE(INSERT). - Sequence state. Run imports and sequence synchronization while writers are stopped. PostgreSQL synchronization may leave gaps; custom descending or cycling sequences are not preserved. SQLite AUTOINCREMENT high-water marks are restored for SQLite targets.
- Timestamps. Text without a zone, as SQLite's
CURRENT_TIMESTAMPwrites it in UTC (SQLite), goes into aTIMESTAMPTZcolumn as UTC, where PostgreSQL would read it in the session's time zone (PostgreSQL). - Views. Views explicitly named in
tablesare read in one query and imported as ordinary tables containing their current rows; view definitions are not recreated. - SQL dumps. PostgreSQL inserts use
OVERRIDING SYSTEM VALUEfor existing ALWAYS identities.includeSchema: falseomits DDL while retaining type-aware literals, generated-column omission and sequence synchronization. - JSON. PostgreSQL JSON arrays, objects and scalars are encoded as JSON for imports and SQL dumps, including string, number and boolean scalars.
- Conflicts.
onConflict: 'ignore'and'replace'useINSERT OR IGNOREandINSERT OR REPLACEon SQLite, andON CONFLICT DO NOTHINGandON CONFLICT (primary key) DO UPDATEon PostgreSQL. On PostgreSQL, replacement keeps an existing ALWAYS identity outside the key; new rows still take explicit identity values.rowsImportedcounts the rows written. Insert failures not handled by the SQL conflict clause are reported inerrors, including underignoreandreplace. - Options.
columnTypes: { 'events.at_ms': 'BIGINT' }sets a column's type.sourceDialectnames the source engine for an export made beforeExportedData.dialectrecorded it. Exports preserve PostgreSQLDATEand zone-lessTIMESTAMPas local calendar text, so the day and clock time do not shift with the process time zone. Zoned timestamps keep their UTC instant.
syncIdentitySequence is optional on SqlDialect so existing custom implementations remain compatible; call it conditionally. Built-in dialects implement it.
The identity column the dialect writes refuses an id an insert gives, as PostgreSQL's GENERATED ALWAYS does. autoIncrementPrimaryKey({ acceptExplicitIds: true }) writes one that takes it, and syncIdentitySequence(table, column) gives the statement that moves the sequence past the inserted ids (null on SQLite, which follows the largest id by itself):
const d = createStorageFeatures(db).dialect;
await db.exec(`CREATE TABLE tags (id ${d.autoIncrementPrimaryKey({ acceptExplicitIds: true })}, label TEXT)`);
await db.run('INSERT INTO tags (id, label) VALUES (?, ?)', [41, 'imported']);
const sync = d.syncIdentitySequence?.('tags', 'id') ?? null;
if (sync) await db.get(sync);Auth.js
createAuthJsAdapter(db) is an Auth.js database adapter. It keeps users, accounts, sessions and verification tokens in the product's own database through this library, on PostgreSQL or SQLite. Pass it to NextAuth v5 or @auth/core; it has the shape of Adapter from @auth/core/adapters, which CI checks, and needs no dependency on it.
import NextAuth from 'next-auth';
import { createDatabase } from '@framers/sql-storage-adapter';
import { createAuthJsAdapter, ensureAuthJsTables } from '@framers/sql-storage-adapter/authjs';
const db = await createDatabase({ url: process.env.DATABASE_URL, strict: true });
await ensureAuthJsTables(db); // or put authJsTablesSql(db) in a migration
export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: createAuthJsAdapter(db), providers: [/* ... */] });- Tables. They are
auth_users,auth_accounts,auth_sessionsandauth_verification_tokens;tablePrefixchanges theauth_. Ids are text, fromcrypto.randomUUID()unless Auth.js passes one orgenerateIdis given. Times aretimestamptzon PostgreSQL and ISO 8601 text on SQLite, and Dates either way in JavaScript. - Deletes.
deleteUserdeletes the user's sessions and accounts first, in one transaction, so it holds on engines that leave foreign keys off. - Verification tokens.
useVerificationTokendeletes and returns the token in one statement (DELETE ... RETURNING), so a token works once. - Passkeys. WebAuthn authenticators are not stored.
- Tests. Auth.js's own adapter test suite runs against it on PostgreSQL, better-sqlite3 and sql.js.
Values
On SQLite, values convert as they bind, positional and named alike:
- a boolean binds as 1 or 0;
- a Date binds as its ISO 8601 text;
- an array or a plain object binds as JSON text;
- undefined binds as NULL;
- bytes (
Uint8Array,Buffer) bind as a blob.
On PostgreSQL, pg binds them itself: an array as a PostgreSQL array, an object as JSON, a Date as a timestamp.
Reading back, pg returns int8 and numeric as strings and a DATE as a Date at local midnight. typeParsers in the PostgreSQL options sets that per pool:
const db = await createDatabase({
url: process.env.DATABASE_URL,
strict: true,
postgres: { typeParsers: { int8: 'bigint', numeric: 'number', date: 'string' } },
});int8:'string'(pg's default),'number'(refuses a value pastNumber.MAX_SAFE_INTEGER) or'bigint'.numeric:'string'(pg's default) or'number'(a double, so it can round).date:'date'(pg's default) or'string'(YYYY-MM-DD).json(json and jsonb):'parse'(pg's default) or'string', the text SQLite returns.
Errors
isUniqueViolation(error) recognises a unique or primary-key violation from any adapter:
- PostgreSQL's SQLSTATE 23505;
- better-sqlite3's
SQLITE_CONSTRAINT_UNIQUEandSQLITE_CONSTRAINT_PRIMARYKEY; - the "UNIQUE constraint failed" message from sql.js, IndexedDB and Capacitor.
It follows cause, so an error your code wraps still counts.
import { isUniqueViolation } from '@framers/sql-storage-adapter';
try {
await db.run('INSERT INTO users (email) VALUES (?)', [email]);
} catch (error) {
if (isUniqueViolation(error)) return { taken: true };
throw error;
}Logging
The library writes its log lines to the console unless a host hands it a logger: which adapter it chose, a fallback, a failed ROLLBACK, an adapter's warnings. pino's logger fits as it is, since each line is a fields object and a message.
import pino from 'pino';
import { createDatabase, setStorageLogger } from '@framers/sql-storage-adapter';
// Every line, from every call
setStorageLogger(pino({ name: 'storage' }));
// Or one call's resolver lines
const db = await createDatabase({ url: process.env.DATABASE_URL, strict: true, logger: requestLogger });setStorageLogger(null) writes to the console again, and quiet: true silences the resolver's choice and fallback lines whatever the logger.
The logger takes the lines of the resolver, the PostgreSQL adapter and the adapter base class. The IndexedDB, Capacitor, sync and Electron modules write to the console directly.
PostgreSQL pool control
PostgresAdapter holds a pg pool. Narrow an adapter to it with db instanceof PostgresAdapter (or db.kind === 'postgres') to reach these:
import { createDatabase, PostgresAdapter } from '@framers/sql-storage-adapter';
const db = await createDatabase({ url: process.env.DATABASE_URL, strict: true, postgres: { max: 10, allowExitOnIdle: true } });
if (db instanceof PostgresAdapter) {
// One connection for the callback: a session advisory lock, then statements on the same session
await db.withConnection(async (connection) => {
await connection.get('SELECT pg_advisory_lock(?)', [42]);
try {
await connection.run('UPDATE jobs SET owner = ? WHERE owner IS NULL', ['worker-1']);
} finally {
await connection.get('SELECT pg_advisory_unlock(?)', [42]);
}
});
// Isolation level and read-only and deferrable modes
await db.transaction(async (trx) => trx.all('SELECT * FROM ledger'), { isolationLevel: 'serializable', readOnly: true, deferrable: true });
// { total, idle, waiting }, or null before open() and after close()
console.log(db.getPoolStats());
}withConnection(fn)returns the connection to the pool whenfnresolves, with whatever session statefnleft (undoSETand release session locks before returning, or useSET LOCALinside a transaction). Whenfnthrows, the connection is closed, andfn's error is rethrown. Whenfnreturns inside a transaction it began and did not end, the connection is closed (rolling it back) and the call rejects; pg 8.21 and later report the transaction status this needs. The connection refuses statements oncefnhas ended, andconnection.transaction()runs one transaction at a time.transaction(fn, options)refuses an isolation level outside'read uncommitted','read committed','repeatable read'and'serializable'before it checks out a connection.allowExitOnIdleis pg's option: Node can exit while every pooled connection is idle. Without it, idle connections keep the process running untilidleTimeoutMillis(10 seconds by default) closes them.- On a segmented adapter,
withConnection()andtransaction()run in the segment, as every connection of its pool does, and aCREATE EXTENSIONthat names no schema still installs intopublic.getPoolStats()counts a pool of yours too.
IndexedDB-Specific Config
import { IndexedDbAdapter } from '@framers/sql-storage-adapter';
const adapter = new IndexedDbAdapter({
dbName: 'my-app-db', // IndexedDB database name
storeName: 'sqliteDb', // Object store name
autoSave: true, // Auto-save to IndexedDB on a timer
saveIntervalMs: 5000, // Batch writes every 5s
durability: 'commit', // Or 'interval' (the default): see below
crossTab: true, // Two tabs of the site keep each other's writes
});
await adapter.open();
await adapter.run('INSERT INTO notes (text) VALUES (?)', ['kept']); // in IndexedDB when this resolves
await adapter.flush(); // stores every write made so farKey Features:
- ✅ SQL execution via sql.js (WASM SQLite)
- ✅ Automatic persistence via IndexedDB (stores SQLite database file as blob)
- ✅ JSON support (SQLite JSON1 extension: json_extract, json_object, json_array, etc.)
- ✅ Prepared statements for performance and security
- ✅ Export/import (Uint8Array SQLite file format)
- ✅ Auto-save with batching (reduce IDB overhead), or each write stored before it resolves (
durability: 'commit') - ✅ Several tabs on one database (
crossTab) - ✅ A database of its own per segment (
segment)
Durability. With durability: 'interval', the default, the auto-save tick stores the writes made since the last save every saveIntervalMs, and a write can be lost when the page goes before the next tick. With durability: 'commit', run(), exec(), a write through all() or get() (INSERT ... RETURNING, or a PRAGMA that sets user_version, application_id, schema_version, page_size, auto_vacuum or encoding), transaction() and importDatabase() resolve once their write is in IndexedDB, committed there (the IndexedDB transaction's complete event, not the put request's success), and reject when IndexedDB refuses or aborts it; the write stays in the database and the next save stores it, unless crossTab's rollbackRefusedSaves gives it up (below). Each save writes the whole database image, so batch many writes in one transaction(). While a transaction begun with BEGIN is open nothing can be saved: its statements resolve at once, and its rows are stored when its COMMIT resolves. flush() stores every write made so far in either mode: it waits for an open transaction(), and rejects while a transaction begun with BEGIN is open, which sql.js's export would end.
Several tabs. Each tab holds the whole database in memory and writes it back whole, so without coordination the tab that saves last overwrites the other's writes. With crossTab, every statement and transaction runs under a Web Lock on the database, on the newest stored image (a tab reloads it when a version stamp stored beside the image says another tab has saved), and is stored before the lock is let go: durability is 'commit' whatever it says, and a tab reads what another tab stored. A transaction holds the other tabs' statements until it commits or rolls back. A transaction begun with BEGIN through run() or exec() cannot be held across tabs: it is rolled back and refused, so use transaction(). Web Locks need a secure context and are in Chrome 69, Firefox 96 and Safari 15.4 and later (on WebKit the feature is on by default and off in Lockdown Mode). Where there are none, the version stamp alone keeps both tabs' writes: a statement that finds another tab saved first runs again on that tab's image, and a transaction() another tab saved over is refused with IndexedDbConflictError, storing nothing of it, for its caller to run again. The adapter says once when it runs without Web Locks. A tab running a version of this package before 0.7 writes no stamp, so a tab with crossTab does not see its saves.
When it saves: every saveIntervalMs, the adapter writes the database image to IndexedDB if anything changed. It skips the tick while a transaction is open, its own or one begun with BEGIN, because sql.js's export closes and reopens the connection, which would end the transaction; the next tick saves it once it has committed. A save counts as done when IndexedDB commits it, not when the put request succeeds. A save that fails or aborts is reported once, and the next tick tries again. A write made while a save is in flight is saved by the next one. A transaction's commit marks its writes for saving, including writes made through all() or get() (INSERT ... RETURNING). close() rolls back a transaction still open and saves the committed state. importDatabase() saves the imported image before it resolves. exportDatabase() throws while a transaction is open.
Memory alone, and the stored image put back:
memoryOnly: truekeeps the database in memory alone, for a browser that refuses IndexedDB (a private window, a blocked storage setting), whereopen()would otherwise reject: IndexedDB is never opened, nothing is stored, andcapabilitiescarries nopersistence.durabilityreads'interval'andcrossTabis off, since there is nothing to store or to share;flush()andclose()store nothing, andexportDatabase()andimportDatabase()work on the image in memory.reload()puts the stored image in place of the one in memory, whatever the version stamp says, and resolves with whether anything changed:truewhen 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 adurability: 'commit'call reports by rejecting, or withdurability: 'interval'one the auto-save has not stored yet), which are then gone;falsewhen 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, undercrossTabthe next coordinated statement, would store the refused writes with its own. A connection withoutcrossTabruns it to read what another connection stored; there, adurability: 'commit'write or aflush()still waiting for the save of a write it puts back rejects instead of resolving as stored. It waits for an opentransaction()to end, holds statements while it runs, and refuses while a transaction begun withBEGINis open.crossTab: { rollbackRefusedSaves: true }gives refused writes up inside the lock. A statement already waiting on the lock when a refusal reaches its caller runs before anything the caller'scatchdoes under the lock, and without this option it stores the refused writes with its own, a read included, so areload()in thatcatchcomes too late. With it, when IndexedDB refuses a save made under the coordination (a statement's, a transaction's, an import's orclose()'s), the adapter reads the stored image back in place of the one in memory before the next coordinated call runs, and the call still rejects. When IndexedDB cannot be read back either, no save stores the refused writes, and the next statement or transaction reads the stored image before it runs, failing while it cannot.
Why IndexedDB Adapter vs sql.js Adapter?
| Feature | IndexedDB Adapter | sql.js Adapter |
|---------|------------------|----------------|
| SQL Engine | sql.js (WASM) | sql.js (WASM) |
| Persistence | ✅ Automatic (saves to IndexedDB after writes) | ⚠️ Manual (you must call db.export() and save yourself) |
| Data survives refresh | ✅ Yes | ❌ No (unless you manually saved) |
| Use Case | Production PWAs, offline-first apps | Edge functions, temporary data, prototyping |
Is IndexedDB Adapter Necessary?
✅ YES, if you need:
- Data to survive page refreshes (production apps)
- Offline-first functionality (PWAs)
- Privacy-first apps (data never leaves browser)
- Zero manual save logic (just works)
❌ NO, if you:
- Only need temporary/in-memory data (edge functions, Cloudflare Workers)
- Are prototyping and don't care about persistence
- Want to manually control when data is saved
- Don't need data to survive refreshes
The Value: IndexedDB adapter provides automatic persistence that sql.js doesn't have. With sql.js alone, your data is lost on page refresh unless you manually export and save it. IndexedDB adapter does this automatically, making it production-ready for persistent client-side storage.
Alternative: You could use sql.js directly and manually save to IndexedDB yourself, but you'd lose:
- Automatic batched saves (performance)
- Reliable persistence (easy to forget manual saves)
- Consistent API across platforms
- Production-ready defaults
Bottom line: IndexedDB adapter is necessary for production web apps that need persistence. For prototypes or edge functions, sql.js alone is fine.
Note: IndexedDB adapter is a wrapper around sql.js that adds IndexedDB persistence. It's not a separate SQL engine—it uses sql.js for all SQL operations and IndexedDB only for storing the database file. Since sql.js is full SQLite WASM, it supports all SQLite features including JSON functions, BLOBs, and full-text search.
Key-Value Store
@framers/sql-storage-adapter/kv holds one key-value contract and its stores. It imports no adapter, so a page that loads it for its key-value store loads neither sql.js nor pg.
import { createDatabase } from '@framers/sql-storage-adapter';
import { createSqlKeyValueStore, namespaced, openIndexedDbStore } from '@framers/sql-storage-adapter/kv';
// In a SQL table, on any adapter: a file, IndexedDB, Capacitor or PostgreSQL
const db = await createDatabase({ segment: 'agentos' });
const kv = await createSqlKeyValueStore(db, { table: 'agentos_kv' });
await kv.set('notes/1', { title: 'One', saved: new Date() });
const mine = namespaced(kv, 'user-42/');
// In one IndexedDB object store, where a page already keeps its values
const page = await openIndexedDbStore({ dbName: 'app', storeName: 'kv' });KeyValueStore has get, set, delete, keys(prefix) and deletePrefix(prefix), and memoryOnly, true when nothing outlives the page or the process. namespaced(store, prefix) scopes a store to a prefix: end each prefix with a character no id holds (ann/, anna/), since the keys under anna are among those under ann.
openIndexedDbStore({ dbName, storeName, version })keeps values in one IndexedDB object store with out-of-line keys. It opens the database atversion(1 by default) and creates the object store only when the open upgrades the database, so a database another page made at that version is read in place. A browser that refuses IndexedDB gets the memory store, andmemoryOnlysays so. This is the store AgentOS'ssrc/core/storage/browserheld, moved unchanged: the same database, object store, version and keys.createSqlKeyValueStore(adapter, { table })keeps values in a table it makes when it is missing, with akeyand avaluecolumn. Each call is one statement on the adapter, and through a transaction's handle it joins the transaction. Keys are listed in the order of their UTF-16 code units, as IndexedDB orders them, and a prefix is read literally (%,_and*are characters). Each value is kept as text and comes back as the structured clone, which IndexedDB applies to every value it stores, gives it back:undefined,-0,NaN, bigints,Date,RegExp,Map,Set,ArrayBuffer, typed arrays andDataView, boxed primitives, errors with their message, stack and cause, objects reached twice and cycles. A value the structured clone refuses (a function, a symbol, aWeakMap, a promise, aProxy) is refused with aDataCloneErrorbefore anything is written, as are the host objects text cannot hold (Blob,File,CryptoKey). The runtime's ownstructuredClonechecks each value first, since only it tells a proxy apart from the object the proxy wraps.migrateIndexedDbKeyValue({ source, adapter, into })moves one namespace of an old IndexedDB key-value store (the oneopenIndexedDbStoreopens) into aKeyValueStoreover an adapter, behind a marker, its old writers locked out by the database's version. It opens the old database atsource.lockVersion, above thefromVersionits writers open it at, so their open fails from then on andopenIndexedDbStoreanswers from memory; writes the records undersource.prefix, without it, into the storeinto(trx)makes, with the marker{ phase: 'copied' }underMIGRATION_MARKER, in one transaction of the adapter; flushes an adapter that answersflush(), so one that stores on an interval holds the copy; deletes the old records; and marks the movedone. It answersmigrated,already,none(nothing under the prefix) orblocked(a tab holds the old version open, and nothing moved: run it again once that tab is closed). A reload resumes from the committed side, and two moves at once copy once. It refuses a store that keeps nothing past the page (memoryOnly: an adapter withoutpersistence) before it opens the old database, so the old records are never deleted while their one copy is in memory. A call made while an earlier one waits on an old tab waits behind it, since IndexedDB runs a database's opens in order.MemoryKeyValueStoreholds values for the page alone, copied in and out withstructuredClone.
Platform Strategy
See PLATFORM_STRATEGY.md for a comprehensive guide on:
- Graceful degradation patterns
- Platform-specific pros/cons
- Performance benchmarks
- Offline-first architectures
- Browser-friendly bundling (dynamic imports prevent server-only dependencies from being bundled)
TL;DR: Use IndexedDB for web, better-sqlite3 for desktop, capacitor for mobile, Postgres for cloud.
Performance Tiers & Cost Optimization
The package exports performance-tier types, the tier presets (TIER_DEFAULTS) and resolvePerformanceConfig(), which turns a tier and overrides into settings. createDatabase() takes no performance option, and no adapter applies a tier: a host that wants one reads the settings and acts on them itself.
import { resolvePerformanceConfig } from '@framers/sql-storage-adapter';
// Analytics/Reporting: the accurate tier's settings (no caching, full validation)
const settings = resolvePerformanceConfig({ tier: 'accurate' });| Tier | Caching | Batching | Validation | Use Case |
|------|---------|----------|------------|----------|
| fast | Aggressive | Yes | Minimal | Development, testing |
| balanced | Moderate | No | Standard | General production |
| accurate | Disabled | No | Full | Analytics, reporting |
| efficient | Moderate | Yes | Minimal | Mobile, IoT |
See guides/OPTIMIZATION_GUIDE.md for detailed configuration options.
Lifecycle Hooks
createDatabase({ hooks }), or withHooks(adapter, hooks) on any adapter, puts lifecycle hooks around each query, write, transaction and error.
- A
beforehook returns the context, changed or not, to go on, or undefined to stop the call. A stoppedgetreturns null,allan empty list, andrunno changes. onAfterQuerycan replace the result.onErrorreturns the error to throw, or undefined to swallow it.- The statements inside a transaction pass through the hooks too, and
onAfterTransactionseesoutcome: 'committed'or'rolled_back'. - The adapter keeps its kind, its own methods and its class.
combineHooks() and filterHooks() compose hook sets.
import { createDatabase, combineHooks, type StorageHooks } from '@framers/sql-storage-adapter';
const myHooks: StorageHooks = {
// Log all writes
onBeforeWrite: async (context) => {
console.log(`Write operation: ${context.statement}`);
return context;
},
// Track metrics after writes
onAfterWrite: async (context, result) => {
if (result.changes > 0) {
console.log(`Modified ${result.changes} rows`);
}
