@broberg/db-sdk
The broberg.ai fleet's shared database client — one thin facade over two transports:
| Transport | For | Env default |
|---|---|---|
libsql (default) | Turso / libSQL, local file: databases | TURSO_DATABASE_URL, TURSO_AUTH_TOKEN |
postgres | PostgreSQL, including Supabase | DATABASE_URL |
createClient() returns the same DbClient from both, so switching transport is
a change at the call to createClient — for the connection.
The SQL is not translated. libSQL takes
?placeholders, Postgres takes$1, and the two dialects differ in DDL, functions andPRAGMA. The facade passes your SQL through untouched: rewriting?into$1would also rewrite a?inside a string literal or a JSON operator. Moving an app between transports means moving its SQL too.
Install
bun add @broberg/db-sdk
# or: npm i @broberg/db-sdk
Use
import { createClient, createReplica, health } from '@broberg/db-sdk';
// Turso (reads TURSO_DATABASE_URL + TURSO_AUTH_TOKEN)
const db = createClient();
// Postgres / Supabase (reads DATABASE_URL)
const pg = createClient({ backend: 'postgres' });
await pg.execute({ sql: 'insert into notes (id, body) values ($1, $2)', args: [1, 'hej'] });
const { rows } = await pg.execute('select id, body from notes');
// rows: [{ id: 1, body: 'hej' }]
// Several statements in ONE transaction — all apply or none do.
await db.batch([
{ sql: 'delete from s where edge = ?', args: ['m1'] },
{ sql: 'insert into s (edge, name) values (?, ?)', args: ['m1', 'x'] },
], 'write');
await health(pg); // { ok: true, transport: 'postgres', version: '16.4' }
health() reports the transport that answered — read it there rather than
inferring it from your own config.
The escape hatch
client.raw is the underlying driver's own client, typed per transport, for
anything the facade does not cover:
const pg = createClient({ backend: 'postgres' });
await pg.raw.listen('events', (payload) => { /* … */ }); // postgres.js
const db = createClient();
await db.raw.executeMultiple(script); // @libsql/client
Embedded replicas (libSQL only)
A local SQLite file kept in sync with a remote Turso primary. There is no
Postgres counterpart, and the config type says so — backend: 'postgres' on a
ReplicaConfig is a compile error.
const edge = createReplica({ path: '/data/buddy.db', syncIntervalSeconds: 30 });
await edge.sync(); // or rely on the interval
Supabase
Use the pooler connection string as DATABASE_URL. Prepared statements are
always off in the Postgres transport, because of what they do to Supabase's
transaction-mode pooler (port 6543). Measured 23 Sep 2026 against a real
Supabase project (eu-north-1), 40 concurrent queries with prepared statements ON:
| Route | Result |
|---|---|
| transaction pooler, 6543 | hung after 5–8 answers — no error, 3 runs of 3 |
| session pooler | 40/40 |
| direct, 5432 | 40/40 |
It does not fail — it hangs, and only under concurrency, so a sequential
test and a local Postgres both stay green. The contract suite runs 60
concurrent queries through the real pooler against a 20-second deadline, and
turning prepared statements back on turns it red with HUNG.
Keep the service-role connection string on the server. Supabase auth belongs in
@broberg/auth, storage in @broberg/media; this package is the SQL connection
only.
ORM
The ORM stays in the consumer. Drizzle takes the driver's client, so hand it
raw:
import { drizzle } from 'drizzle-orm/postgres-js';
const orm = drizzle(createClient({ backend: 'postgres' }).raw, { schema });
API
| Export | Signature |
|---|---|
createClient | (cfg?: LibsqlConfig) => DbClient<LibsqlClient> · (cfg: PostgresConfig) => DbClient<Sql> |
createReplica | (cfg: ReplicaConfig) => ReplicaClient — libSQL only |
health | (client) => Promise<{ ok, transport, version }> — throws if unreachable |
DbClient: transport, raw, execute(stmt), batch(stmts, mode?), close().
A statement is string or { sql, args? }. A result is
{ rows, columns, rowsAffected }, with rows as plain objects keyed by column.
Values are the driver's own — the facade unifies the shape, not the types. Measured on the same query:
| SQL value | libsql | postgres |
|---|---|---|
count(*), bigint | number | string ('1') |
numeric / 1.5 literal | number | string ('1.5') |
integer | number | number |
timestamptz | string | Date |
jsonb | string | parsed object |
Postgres returns int8 and numeric as strings because a JS number cannot hold an
int8 above 2^53, and the facade does not convert them for the same reason —
a silent precision loss is worse than a visible string. Wrap with Number(...)
where you know the range is safe. The contract suite pins these, so a driver
changing them turns it red.
batch modes are 'write' and 'deferred'. There is no 'read' mode: a
libSQL read batch against a local file was measured executing an INSERT that
Postgres refused, and a read-only promise that holds on one transport only is not
one the facade can make.
Migrating from 0.1.x
createClient() used to return @libsql/client's own Client. It now returns
DbClient. What changed for a caller:
execute(string | { sql, args })andbatch(stmts, 'write')— unchanged.rows[i]['column']— unchanged. Positional accessrows[i][0]is gone: rows are plain objects now.lastInsertRowid— gone from the facade (it is a SQLite concept). Useclient.raw.execute(...), orreturning id.- Anything else on the libSQL client (
executeMultiple,transaction,sync): reach it viaclient.raw. - Drizzle:
drizzle(createClient(), { schema })— the pattern this README used to show — no longer works, because drizzle wants the driver's client. Writedrizzle(createClient().raw, { schema }). Measured working for both transports (drizzle-orm/libsqlanddrizzle-orm/postgres-js). type Clientstill exists as a deprecated alias ofDbClient.
buddy (apps/server), the one known 0.1.x consumer, typechecks and passes its own
db-sdk test against this version with no source changes.
Tests
DB_SDK_TEST_PG_URL=postgres://… bun test
One contract suite runs the same cases against libSQL (a local file), a
real Postgres, and — when DB_SDK_TEST_PG_POOLER_URL is set — a real
Supabase transaction pooler. Without DB_SDK_TEST_PG_URL the suite fails rather than
skipping Postgres — a skipped transport reads as a green one. CI provides a
Postgres service container.
License
FSL-1.1-Apache-2.0 © Christian Broberg