@joyful-tools/drizzle
v0.0.3
Published
A lean Result-flavored wrapper around a [drizzle-orm](https://orm.drizzle.team) database instance.
Readme
@joyful/drizzle
A lean Result-flavored wrapper around a drizzle-orm database instance.
Wrap the db once, lift queries into AsyncResult, and roll back transactions
when a callback returns Err. No Proxy, no AsyncLocalStorage, no custom
interpreter — a plain class with bound run / tx.
Installation
drizzle-orm is required as a peer.
# npm
npm install @joyful-tools/drizzle drizzle-orm
# pnpm
pnpm add @joyful-tools/drizzle drizzle-orm
# bun
bun add @joyful-tools/drizzle drizzle-orm
# npm (from JSR)
npx jsr add @joyful/drizzle
# pnpm 10.9+ (from JSR)
pnpm add jsr:@joyful/drizzle
# yarn 4.9+ (from JSR)
yarn add jsr:@joyful/drizzle
# deno
deno add jsr:@joyful/drizzleThe examples below use
@joyful/drizzleand@joyful/result(the JSR names) in imports. If you installed from npm, use@joyful-tools/drizzleand@joyful-tools/resultinstead.
Why
- Queries are data.
runturns drizzle thenables intoAsyncResult<T, DatabaseError>so failures compose with the rest of@joyful/resultinstead of throwing. Errrolls back.txcommits onOkand rolls back when the callback returnsErr— including nested savepoints.- Session-first composition. Helpers take
s: Sessionfirst, so the same function runs asmyQuery(db, ...)ordb.tx(myQuery, ...). - Tiny surface.
jdb,run,tx,matches. Dialect-agnostic: the package imports no concrete drivers.
Usage
Setup
import { drizzle } from "drizzle-orm/libsql";
import { jdb, type SessionOf } from "@joyful/drizzle";
import * as schema from "./schema.ts";
export const db = jdb(drizzle(client, { schema }));
export type Session = SessionOf<typeof db>;db and the s received in a tx callback are both assignable to Session.
If you prefer drizzle's table.$inferSelect style, typeof db.$inferSession is
the same type (type-only — undefined at runtime):
export type Session = typeof db.$inferSession;Lazy initialization
jdb also accepts a zero-arg initializer. It runs on first use (first query,
tx, or $client / $driver access) and the result is memoized — handy when
building the client needs config that isn't available at import time:
export const db = jdb(() => {
const { databaseUrl, databaseAuthToken } = getCoreConfig();
const client = createClient({
url: databaseUrl,
authToken: databaseAuthToken,
});
return drizzle(client, { schema });
});
export type Session = typeof db.$inferSession;The inferred type is the same JoyDb as the eager form. A throw inside the
initializer surfaces as DatabaseError from whichever run / tx touched the
db first.
run — lift a query
The callback form is the one to use day-to-day. The raw drizzle instance is
scoped to the call, and sync throws during builder construction become
DatabaseError too:
const rows = await db.run((d) =>
d.select().from(boards).where(eq(boards.workspaceId, wsId))
);
// AsyncResult<Board[], DatabaseError>You can also pass a thenable directly (db.run(d.select().from(boards))), but
construction then happens outside the try boundary.
Variadic refiners
When extra arguments are present, the base builder is switched to .$dynamic()
and each (qb) => qb is applied left-to-right. The result type comes from the
last refiner:
const page = await db.run(
(d) => d.select().from(boards),
(qb) => qb.where(eq(boards.workspaceId, wsId)),
(qb) => qb.limit(10).offset(20),
);matches
AND together equality filters, skipping undefined entries. null values
become IS NULL (a plain = NULL never matches in SQL). Returns undefined
when nothing remains, so it nests inside drizzle's and() / or():
import { and, eq } from "drizzle-orm";
import { matches } from "@joyful/drizzle";
db.run((d) =>
d.select().from(boards).where(and(
matches(boards, { workspaceId, id: boardId }),
eq(boards.archived, false),
))
);tx — Result-aware transactions
The callback either returns Result / AsyncResult / Promise<Result>, or is
an async function* — the generator is driven through Result.run for you.
Ok commits; Err (returned or yield*ed) rolls back and is returned as-is.
Driver throws become DatabaseError. Extra arguments after the function are
passed through:
const created = await db.tx(async function* (s) {
yield* s.run((d) => d.insert(workspaces).values({ id, name }));
const [board] = yield* s.run((d) =>
d.insert(boards).values({ id: boardId, workspaceId: id, title })
.returning()
);
return board;
});
await db.tx(createBoard, workspaceId, input);The explicit form db.tx((s) => Result.run(async function* () { ... })) still
works and is equivalent.
Nesting / savepoints
JoyDb<Tx>.tx calls tx.transaction(...), which is drizzle's savepoint. There
is no special nesting API — inner Err rolls back the inner writes only, and if
the outer callback still returns Ok the outer work commits:
await db.tx(async (s) => {
const outer = await s.run((d) => d.insert(workspaces).values(workspace));
if (outer.isErr()) return outer;
const inner = await s.tx(async (s2) => {
const wrote = await s2.run((d) => d.insert(boards).values(board));
if (wrote.isErr()) return wrote;
return Result.err(new Conflict({}));
});
// inner writes are gone; workspace remains if we return Ok
return Result.ok("handled");
});Session-first convention
Partial / composable queries are plain functions with s: Session as the
first parameter:
- Plain execution:
myQuery(db, ...args) - Atomic:
db.tx(myQuery, ...args)ordb.tx((s) => myQuery(s, ...args)) - Composition:
yield* subQuery(s, ...)insideResult.run
import { Result, taggedError } from "@joyful/result";
import type { Session } from "./db.ts";
class NotFound extends taggedError("NotFound") {}
function getWorkspace(s: Session, id: string) {
return Result.run(async function* () {
const [row] = yield* s.run((d) =>
d.select().from(workspaces).where(eq(workspaces.id, id))
);
if (!row) yield* new NotFound({});
return row;
});
}
function createBoard(s: Session, workspaceId: string, title: string) {
return Result.run(async function* () {
const workspace = yield* getWorkspace(s, workspaceId);
const [board] = yield* s.run((d) =>
d.insert(boards).values({
id: crypto.randomUUID(),
workspaceId: workspace.id,
title,
}).returning()
);
return board;
});
}
// not atomic: a later failure leaves the workspace write in place
await createBoard(db, wsId, "Roadmap");
// atomic: any Err rolls back every write in the helper
await db.tx(createBoard, wsId, "Roadmap");Helpers can also be async generators — db.tx drives them through
Result.run, and they compose into other generators with yield*:
async function* createBoard(s: Session, workspaceId: string, title: string) {
const workspace = yield* getWorkspace(s, workspaceId);
const [board] = yield* s.run((d) =>
d.insert(boards).values({
id: crypto.randomUUID(),
workspaceId: workspace.id,
title,
}).returning()
);
return board;
}
// atomic: db.tx runs the generator inside the transaction
await db.tx(createBoard, wsId, "Roadmap");
// composes into a larger transaction
await db.tx(async function* (s) {
const board = yield* createBoard(s, wsId, "Roadmap");
yield* s.run((d) => d.insert(items).values(seedItems(board.id)));
return board;
});
// plain (non-atomic) execution needs the Result.run wrapper
await Result.run(() => createBoard(db, wsId, "Roadmap"));run and tx are bound, so helpers can also destructure a session:
function listBoards({ run }: Session, workspaceId: string) {
return run((d) =>
d.select().from(boards).where(eq(boards.workspaceId, workspaceId))
);
}$client / $driver
Escape hatches for APIs this wrapper does not cover:
$client— the wrapped drizzle instance (root db or the current transaction)$driver— drizzle's own.$client(libsqlClient,pgPool, …).neveron a transaction session
await db.$driver.execute("select 1");
await db.$client.query.boards.findMany({
where: eq(boards.workspaceId, wsId),
});API
jdb(client)— wrap a drizzle database or transaction. Also takes a lazy() => clientinitializer, resolved and memoized on first use.JoyDb— the wrapper class.runandtxare bound arrow properties.DatabaseError— tagged error for driver / query failures; original error iscause.matches(table, values)—ANDofeqfor defined keys (null→IS NULL);undefinedif empty.SessionOf<typeof db>— session type shared by the root db andtxcallbacks. Also available astypeof db.$inferSession(type-only property).TxOf<C>— transaction client ofC(TxOf<Tx> = Tx).DriverOf<C>— drizzle.$clientdriver handle, orneveron a transaction.
License
MIT
