Keel ships a small, driver-agnostic query builder. It generates
parameterized SQL and runs it through a Connection you provide — so it works
with any driver (Cloudflare D1, Neon/Postgres, PlanetScale, Turso, better-sqlite3,
pg). The core never imports a database driver, so it stays edge-safe.
Connect
Register a connection once, in a service provider. The Connection interface is
two methods — adapt them to your driver:
import { setConnection, type Connection } from "@shaferllc/keel/core";
const connection: Connection = {
select: (sql, bindings) => d1.prepare(sql).bind(...bindings).all().then((r) => r.results),
write: async (sql, bindings) => {
const r = await d1.prepare(sql).bind(...bindings).run();
return { rowsAffected: r.meta.changes, insertId: r.meta.last_row_id };
},
};
setConnection(connection, "sqlite"); // "sqlite" | "mysql" | "postgres"
The dialect only affects placeholder style (? vs Postgres $1). select
returns the rows (Row[]); write returns a WriteResult (rowsAffected,
optional insertId). Everything the builder does bottoms out in these two
methods, which is why the same app runs on Node and the edge — only the
connection changes. The type parameter on db<T>() types the results; the
Connection itself just deals in Rows.
Ready-made adapters
You don't have to hand-write the bridge for the common drivers — Keel ships
Connection adapters as optional subpath imports. Each takes your driver
instance and returns a Connection. They import no driver themselves (the client
is duck-typed), so Keel's core stays dependency-free and nothing is bundled until
you import it:
// Cloudflare D1 (sqlite)
import { d1Connection } from "@shaferllc/keel/db/d1";
setConnection(d1Connection(env.DB), "sqlite");
// Postgres — pg (Node) or @neondatabase/serverless (edge)
import { pgConnection } from "@shaferllc/keel/db/pg";
import { Pool } from "pg";
setConnection(pgConnection(new Pool({ connectionString })), "postgres");
// libSQL / Turso (sqlite, Node + edge)
import { libsqlConnection } from "@shaferllc/keel/db/libsql";
import { createClient } from "@libsql/client";
setConnection(libsqlConnection(createClient({ url, authToken })), "sqlite");
Install only the driver you use — it's a peer, not a Keel dependency. Postgres
note: INSERT returns an id only with a RETURNING clause, so insertGetId()
needs RETURNING id on Postgres; the D1 and libSQL adapters return the last
insert id natively.
Multiple databases
setConnection registers the default connection. To talk to more than one
database at once — a Postgres primary and a SQLite/D1 cache, a separate reporting
warehouse, a per-tenant shard — register each by name with addConnection, and
each keeps its own dialect:
import { setConnection, addConnection } from "@shaferllc/keel/core";
setConnection(primary, "postgres"); // the default
addConnection("reporting", warehouse, "postgres");
addConnection("cache", d1Cache, "sqlite");
Route a single query with a second argument to db():
await db("users").where("active", true).get(); // default
await db("events", "reporting").where("kind", "signup").count();
Or grab a reusable handle with connection(name) — it exposes table() plus the
raw select/write bridge, all dialect-adjusted for that database:
import { connection } from "@shaferllc/keel/core";
const reporting = connection("reporting");
await reporting.table("events").latest().limit(100).get();
await reporting.write("REFRESH MATERIALIZED VIEW daily_signups", []);
A whole model can live on a connection — set static connection
and every query, save, and relation for that model routes there:
class Event extends Model {
static table = "events";
static connection = "reporting"; // reads, writes, and relations use "reporting"
}
setDefaultConnection(name) switches which registered connection the unnamed
db(table) (and any model without a static connection) uses — handy for
request-scoped tenant selection. connectionNames() lists what's registered.
An unregistered connection name doesn't fail when you build a query, only when
it runs — so a misconfigured name surfaces as a rejected read/write, not a
construction-time throw.
Queries & models
Building and running queries — where, joins, aggregates, inserts, updates,
pagination — lives in the Query Builder guide. The
ORM adds an active-record layer (models, relationships, events,
scopes) on top of the same builder.
Transactions
Two related writes should either both land or neither should. transaction()
commits when your callback returns and rolls back if it throws:
import { transaction, db } from "@shaferllc/keel/core";
await transaction(async () => {
await db("orders").insert(order);
await db("stock").where("id", id).decrement("count"); // a throw here undoes the insert
});
The error still reaches you — it's rethrown after the rollback. Nothing is swallowed.
Queries inside are ambient
You don't have to thread a transaction object through your code. db(), models,
and relations all pick up the open transaction automatically:
await transaction(async () => {
const user = await User.create({ email }); // the model is in the transaction
await user.related("posts").create({ title }); // so is the relation
await db("audit").insert({ userId: user.id }); // and the raw builder
});
That works because the transaction lives in AsyncLocalStorage, not a module
global — so two requests running transactions at the same time can't steal each
other's connection.
If you'd rather be explicit, the callback gets a handle:
await transaction(async (tx) => {
await tx.table("orders").insert(order);
await tx.write("UPDATE stock SET count = count - 1 WHERE id = ?", [id]);
});
tx.rollback() abandons the transaction without committing. inTransaction()
tells you whether one is open.
Nesting uses savepoints
A transaction() inside another doesn't open a second one — databases don't have
those. It takes a savepoint, so an inner failure rolls back only the inner
work and the outer transaction carries on:
await transaction(async () => {
await db("orders").insert(order); // survives
try {
await transaction(async () => {
await db("items").insert(item);
throw new Error("out of stock"); // only this is rolled back
});
} catch {
// handle it
}
await db("audit").insert(entry); // still in the outer transaction
});
// the outer transaction commits: the order and the audit row are both saved
Without savepoints, a nested helper's failure would silently abandon its caller's writes too — which is the sort of bug you find in production, months later.
Drivers and the pooling trap
A transaction needs every statement to run on one connection. A connection
pool hands each statement to whichever connection is free — so issuing BEGIN
through a pool wraps nothing: the INSERT after it can land on a different
connection entirely, the COMMIT commits nothing, and a failure half-writes.
It looks like it works. It doesn't.
So a pooled driver implements begin() on its Connection, checking one
connection out and running the whole transaction on it. Keel's Postgres adapter
does this automatically when you hand it a Pool (it checks for connect()), and
releases the connection afterwards even if the COMMIT throws.
| Driver | Transactions |
|---|---|
Postgres (Pool) |
✅ a dedicated connection is checked out |
Postgres (Client), SQLite, libSQL |
✅ BEGIN / COMMIT on the one connection they have |
| Cloudflare D1 | ❌ — no interactive transactions; use database.batch([...]) |
D1 can't hold a transaction open across awaits, so transaction() on it throws
a clear error rather than letting a BEGIN fail cryptically. A transaction that
quietly isn't one is far worse than one that refuses to start.
Writing your own driver? Implement begin(): Promise<TransactionConnection> if it
pools. If it owns a single connection, you can leave it out and Keel will use
BEGIN/COMMIT/ROLLBACK.
Typed rows
Pass a row type for typed results — it flows through to get() and first():
type User = {
id: number;
email: string;
};
const user = await db<User>("users").where("id", 1).first(); // User | null
const all = await db<User>("users").get(); // User[]
The type is a compile-time convenience; it doesn't validate the shape at runtime.
Use a
typealias, not aninterface, for the row type. The builder's type parameter is constrained toRow(Record<string, unknown>), which atypesatisfies via an implicit index signature but aninterfacedoes not.
Related
An active-record Model layer and migrations
build on this builder — reach for them for CRUD and schema work, and drop back to
db() for anything they don't cover.
API reference
db(table)
db<T extends Row = Row>(table: string, connectionName?: string): QueryBuilder<T>
Starts a new query against table, on the default connection or a named one.
The optional type parameter types the rows returned by get()/first().
db("users"); // QueryBuilder<Row>, default connection
db<{ id: number }>("users"); // typed rows
db("events", "reporting"); // the "reporting" connection
Notes: returns a fresh builder each call — nothing is shared between queries.
No SQL runs until a terminal method (get/first/count/exists) or a write
(insert/update/delete) is awaited.
setConnection(conn, dialect?)
setConnection(conn: Connection, driverDialect?: Dialect): void
Registers the connection every db() query runs through, plus the dialect
(default "sqlite").
setConnection(connection, "postgres");
Notes: registers the "default" connection — the last call wins. Calling
db() before any connection is registered throws No database connection… on
the first query. The dialect only changes placeholder rendering (? → $1, $2
for Postgres).
addConnection(name, conn, dialect?)
addConnection(name: string, conn: Connection, driverDialect?: Dialect): void
Registers a named connection alongside the default and any others — the way to
use more than one database. Reach it with db(table, name), connection(name),
or a model's static connection = name.
addConnection("reporting", warehouse, "postgres");
connection(name?)
connection(name?: string): ConnectionHandle
Returns a handle to a registered connection (or the default): table(name) to
start a query, select/write for raw SQL (dialect-adjusted, ? placeholders),
and dialect.
const reporting = connection("reporting");
await reporting.table("events").count();
await reporting.select("SELECT 1", []);
setDefaultConnection(name) · connectionNames() · clearConnections()
setDefaultConnection(name: string) picks which registered connection the
unnamed db(table) and connectionless models use (throws if name isn't
registered). connectionNames() returns the registered names.
clearConnections() unregisters everything — a test helper.
Interfaces & types
Connection
interface Connection {
select<T = Row>(sql: string, bindings: unknown[]): Promise<T[]>;
write(sql: string, bindings: unknown[]): Promise<WriteResult>;
}
The seam between the builder and your driver. select runs any row-returning
query and resolves to the rows; write runs an INSERT/UPDATE/DELETE and resolves
to a WriteResult. Implement it once per driver — the two-method surface is
deliberately tiny so any driver (or a mock in tests) fits.
const mock: Connection = {
select: async () => [{ id: 1 }],
write: async () => ({ rowsAffected: 1, insertId: 1 }),
};
WriteResult
interface WriteResult {
rowsAffected: number;
insertId?: number | string;
}
Returned by write (and thus insert/update/delete). insertId is optional
because not every driver or statement produces one.
Row
type Row = Record<string, unknown>
A database row — the default shape for query results and write payloads.
Dialect
type Dialect = "sqlite" | "mysql" | "postgres"
Selects placeholder rendering. Only Postgres differs ($1, $2, …); the others
use ?.
Operator
type Operator = "=" | "!=" | "<" | "<=" | ">" | ">=" | "like"
The comparison operators accepted by the three-argument where/orWhere.