Adapters
sqlfu doesn’t ship its own database driver. It sits on top of whichever
database client you already use and gives you the same typed client surface on
top.
This page lists the runtime adapters that ship in sqlfu, with a copy-paste snippet for each. The SQLite-compatible adapters have end-to-end guides today. createNodePostgresClient() is runtime-only: it adapts a pg-compatible pool for application queries, while the broader @sqlfu/pg dialect/toolchain docs are still in progress.
For an end-to-end setup, use the matching guide:
| Runtime | Guide |
|---|---|
| Durable Objects | Durable Objects |
| Cloudflare D1 | Cloudflare D1 |
| Node SQLite / better-sqlite3 / native libsql | Node SQLite |
| Bun SQLite | Bun SQLite |
| Turso and libSQL | Turso and libSQL |
| Expo SQLite | Expo SQLite |
| sqlite-wasm | sqlite-wasm |
If you already know which driver you want to use, jump to the section below. If you’re picking from scratch, see Choosing an adapter at the bottom. For the shared client contract, see Runtime client.
Sync stays sync
Section titled “Sync stays sync”Most query libraries force every database call to be async, even when the
underlying driver is synchronous. sqlfu preserves the sync or async nature of the
driver you brought:
- Give it
better-sqlite3and you get aSyncClient.client.all(...)returns rows, not aPromise<rows>. - Give it
@libsql/clientand you get anAsyncClient. Same surface, but promise-returning. - Generated wrappers and
applyMigrations()follow the same split.
That is why the matrix below has a Sync/Async column. Swapping from one sync driver to another is usually a one-line boundary change. Swapping from sync to async is a real application change, and sqlfu leaves that visible in the types.
Prepared statements
Section titled “Prepared statements”Generated wrappers are the main application path: put stable queries in .sql files, run sqlfu generate, and call the generated function. client.prepare(sql) is the lower-level client API for SQL that needs to stay dynamic or ad-hoc without reaching through to client.driver.
Use it when you want to reuse one statement handle, bind named parameters directly, or call .all() and .run() against the same SQL string. The handle follows the same sync/async split as the client:
interface PostRow { id: number; title: string;}
using stmt = syncClient.prepare<PostRow>(` select id, title from posts where slug = :slug`);
const rows = stmt.all({slug: 'hello-world'});interface PostRow { id: number; title: string;}
await using stmt = asyncClient.prepare<PostRow>(` select id, title from posts where slug = :slug`);
const rows = await stmt.all({slug: 'hello-world'});Prepared handles expose .all(params), .run(params), and .iterate(params). params can be a positional array ([id]) or a named object ({slug}). Adapters that have native prepared statements hold the driver handle and dispose it when the using scope exits. Adapters whose driver only exposes an exec/execute API provide a compatible shim: the method still exists, but each call re-issues the SQL through the driver.
Named object keys are bare identifiers. SQL written as :slug, @slug, or
$slug all bind from {slug: 'hello-world'}; sqlfu adapters translate that
shape to the underlying driver, including sqlite-wasm. For positional ?
placeholders, pass an array.
Compatibility matrix
Section titled “Compatibility matrix”| Driver package | Runtime | Where it runs | Sync/Async | Adapter factory |
|---|---|---|---|---|
better-sqlite3 | Node | Local file / in-memory | Sync | createBetterSqlite3Client |
node:sqlite | Node ≥ 22 | Local file / in-memory | Sync | createNodeSqliteClient |
bun:sqlite | Bun | Local file / in-memory | Sync | createBunClient |
libsql | Node | Local file / embedded replica | Sync | createLibsqlSyncClient |
@libsql/client | Node / Deno / edge runtimes | Local file: or remote libsql:// | Async | createLibsqlClient |
@tursodatabase/database | Node | Local / embedded (Turso’s next engine) | Async | createTursoDatabaseClient |
@tursodatabase/serverless | Any fetch() runtime | Remote Turso Cloud (HTTP) | Async | createTursoServerlessClient |
@tursodatabase/sync | Node | Local file + sync to Turso Cloud | Async | createTursoDatabaseClient |
Cloudflare D1Database | Cloudflare Workers | D1 | Async | createD1Client |
Durable Object SqlStorage | Cloudflare Durable Objects | Per-DO embedded SQLite | Sync | createDurableObjectClient |
expo-sqlite | Expo (React Native) | On-device SQLite | Async | createExpoSqliteClient |
@sqlite.org/sqlite-wasm | Browsers | OPFS / in-memory | Async | createSqliteWasmClient |
pg | Node | PostgreSQL | Async | createNodePostgresClient |
Every factory takes the underlying driver’s database/connection object as its single argument and returns a sqlfu client. None of these drivers are peer dependencies: install only the one you actually use.
Better Auth schema generation
Section titled “Better Auth schema generation”sqlfu/better-auth is a Better Auth adapter wrapper, not a sqlfu database
driver adapter. It exists so Better Auth’s auth generate command can write
the auth-owned part of sqlfu’s configured definitions.sql, while sqlfu still
owns database diffs and migrations.
import {betterAuth} from 'better-auth';import {sqlfuBetterAuthAdapter} from 'sqlfu/better-auth';
export const auth = betterAuth({ database: sqlfuBetterAuthAdapter(),});Then run generation, draft the sqlfu migration, and apply it:
npx auth@latest generate --yesnpx sqlfu draftnpx sqlfu migrateSee Better Auth for first-run append behavior, the managed region format, runtime adapter delegation, and troubleshooting.
Local and embedded
Section titled “Local and embedded”node:sqlite (Node)
Section titled “node:sqlite (Node)”import {DatabaseSync} from 'node:sqlite';import {createNodeSqliteClient} from 'sqlfu';
const db = new DatabaseSync('app.db');const client = createNodeSqliteClient(db);bun:sqlite
Section titled “bun:sqlite”import {Database} from 'bun:sqlite';import {createBunClient} from 'sqlfu';
const db = new Database('app.db');const client = createBunClient(db);better-sqlite3 (Node)
Section titled “better-sqlite3 (Node)”import Database from 'better-sqlite3';import {createBetterSqlite3Client} from 'sqlfu';
const db = new Database('app.db');const client = createBetterSqlite3Client(db);libsql (native embedded)
Section titled “libsql (native embedded)”import Database from 'libsql';import {createLibsqlSyncClient} from 'sqlfu';
const db = new Database('app.db');const client = createLibsqlSyncClient(db);@tursodatabase/database
Section titled “@tursodatabase/database”The new Turso-built engine, with native bindings. Same API shape as @tursodatabase/sync.
import {connect} from '@tursodatabase/database';import {createTursoDatabaseClient} from 'sqlfu';
const db = await connect('app.db'); // or ':memory:'const client = createTursoDatabaseClient(db);Remote / cloud
Section titled “Remote / cloud”@libsql/client: Turso Cloud (or local file:)
Section titled “@libsql/client: Turso Cloud (or local file:)”import {createClient} from '@libsql/client';import {createLibsqlClient} from 'sqlfu';
const raw = createClient({ url: process.env.TURSO_DATABASE_URL!, // libsql://... authToken: process.env.TURSO_AUTH_TOKEN,});const client = createLibsqlClient(raw);The same adapter works against a local file when url is file:app.db.
@tursodatabase/serverless: HTTP, no native deps
Section titled “@tursodatabase/serverless: HTTP, no native deps”import {connect} from '@tursodatabase/serverless';import {createTursoServerlessClient} from 'sqlfu';
const conn = connect({ url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN,});const client = createTursoServerlessClient(conn);No native bindings: runs on any runtime with fetch() (Vercel Edge, Cloudflare Workers, Deno Deploy, AWS Lambda).
@tursodatabase/sync: local file, synced to Turso
Section titled “@tursodatabase/sync: local file, synced to Turso”Same adapter as @tursodatabase/database; the difference is at the driver level (the driver keeps a local file and knows how to push()/pull() to a remote Turso DB).
import {connect} from '@tursodatabase/sync';import {createTursoDatabaseClient} from 'sqlfu';
const db = await connect({ path: 'local.db', url: process.env.TURSO_DATABASE_URL, authToken: process.env.TURSO_AUTH_TOKEN,});await db.connect();const client = createTursoDatabaseClient(db);
// sync at your own cadence; sqlfu doesn't own thisawait db.push();await db.pull();Postgres runtime
Section titled “Postgres runtime”createNodePostgresClient() adapts a pg pool for application queries. This
adapter is runtime-only and lives in the main sqlfu package. The broader
Postgres dialect/toolchain work lives in @sqlfu/pg; fuller docs and examples
for that package are still in progress.
import {Pool} from 'pg';import {createNodePostgresClient} from 'sqlfu';
const pool = new Pool({ connectionString: process.env.DATABASE_URL,});const client = createNodePostgresClient(pool);Cloudflare D1
Section titled “Cloudflare D1”import {createD1Client} from 'sqlfu';
export default { async fetch(_req, env: {DB: D1Database}) { const client = createD1Client(env.DB); // ... },};Cloudflare Durable Object (per-DO SQLite)
Section titled “Cloudflare Durable Object (per-DO SQLite)”import {DurableObject} from 'cloudflare:workers';import {createDurableObjectClient} from 'sqlfu';import {migrate} from '../migrations/.generated/migrations.ts';
export class Counter extends DurableObject { client: ReturnType<typeof createDurableObjectClient>;
constructor(ctx: DurableObjectState, env: Env) { super(ctx, env);
this.client = createDurableObjectClient(ctx.storage);
migrate(this.client); }}Pass ctx.storage, not ctx.storage.sql. The SQL handle is enough for queries, but the full storage object gives sqlfu access to Cloudflare’s transactionSync() API, so each migration is applied inside a real Durable Object storage transaction. If you need a query-only escape hatch, pass {sql: ctx.storage.sql} explicitly.
The generated migration module is emitted by sqlfu generate when migrations is configured. Commit migrations/*.sql; import migrate from migrations/.generated/migrations.ts into the Worker bundle; let every Durable Object instance call it during startup. migrate() is idempotent: once a given Durable Object’s private SQLite database has a row in sqlfu_migrations, that migration is skipped on later starts.
Mobile / browser
Section titled “Mobile / browser”Expo SQLite
Section titled “Expo SQLite”import * as SQLite from 'expo-sqlite';import {createExpoSqliteClient} from 'sqlfu';
const db = await SQLite.openDatabaseAsync('app.db');const client = createExpoSqliteClient(db);@sqlite.org/sqlite-wasm (browsers)
Section titled “@sqlite.org/sqlite-wasm (browsers)”import sqlite3InitModule from '@sqlite.org/sqlite-wasm';import {createSqliteWasmClient} from 'sqlfu';
const sqlite3 = await sqlite3InitModule();const db = new sqlite3.oo1.DB('file:app.db?vfs=opfs');const client = createSqliteWasmClient(db);Choosing an adapter
Section titled “Choosing an adapter”A few rules of thumb:
- You want fast local dev and then “a real database” in prod: use
@libsql/clientfor both. Seturl: 'file:app.db'locally andlibsql://...in prod. No code changes needed at the sqlfu layer. - You need zero native deps (edge workers, serverless, Lambda):
@tursodatabase/serverlessor CloudflareD1. - You want an embedded database that can sync to the cloud:
@tursodatabase/sync. - You want the fastest pure-local experience on Node:
better-sqlite3or@tursodatabase/database. - You’re on Bun:
bun:sqlite. - You’re on Node ≥ 22 and want no extra deps:
node:sqlite. - Mobile / browser: Expo SQLite or
sqlite-wasm.
And probably the best thing: you don’t have to choose one. You can define separate entrypoints for your local, test and production environments and write your application logic using the shared sqlfu Client interface, then pass around two different clients, and they’ll behave in the same way.
Writing a custom adapter
Section titled “Writing a custom adapter”Each adapter is a thin function that wraps a driver into a SyncClient or AsyncClient. The existing adapters in src/adapters/ are the reference. Shape:
all(query)→ rowsrun(query)→{rowsAffected?, lastInsertRowid?}raw(sql)→ multi-statement string executioniterate(query)→ row iteratorprepare(sql)→ reusable statement handle with.all,.run,.iterate, and a dispose methodtransaction(fn)→ runfninside a transaction (thesqlfu/core/sqlitehelpers providesurroundWithBeginCommitRollback{Sync,Async}that implement this for you usingbegin/commit/rollback)
If your driver has a native prepared statement, wrap it. If it does not, implement prepare(sql) as a small shim that captures the SQL string and calls the driver’s normal execution method on each .all, .run, or .iterate. The method should still return a disposable handle so callers can use using / await using uniformly.
If your driver is SQLite-compatible but not listed, opening a PR with a new adapter file + a test file in test/adapters/ is usually a small change.