db-result
v0.3.0
Published
Database failures as better-result tagged errors — Result<T, DbError>, retry-safe, driver-agnostic.
Maintainers
Readme
db-result
Database failures as better-result tagged errors (
Result<T, DbError>), retry-safe, driver-agnostic. Attempt the insert — that is the uniqueness check. We classify the failure and decide what's worth retrying.
bun add better-result db-resultBuilt on better-result. You adopt its
Result model too: tryDb returns Result<T, DbError>, folds with
matchErrorPartial, composes in Result.gen.
Classify every driver failure into one of 14
db/*tags, the same across every driver and ORM (pg, SQLite incl. D1, mysql, mssql, Prisma, Kysely, Drizzle):// constraints data contention connection unique-violation data-error deadlock connect-failure foreign-key-violation lock-timeout connection-lost not-null-violation transaction-aborted check-violation // identity // other authentication-failed sql-syntax-error authorization-failed query-failureunique-violation,foreign-key-violation,not-null-violationandcheck-violationeach carry the driver'sconstraintidentifier.connect-failuremeans the channel was never established (DNS, refused, timeout) — safe to retry;connection-lostmeans it died mid-query — ambiguous, never auto-retried. That split is deliberate: connect-phase vs mid-query.data-erroris a value problem (too long, overflow, bad input);deadlockincludes serialization failures (40001);lock-timeoutincludes SQLiteBUSY/LOCKED;transaction-aborted(25P02) means the whole transaction is dead;query-failureis the known-but-unspecific catch-all.No
instanceof, noerror.code === "23505".Narrow the error union from the query's own type: pass the builder value (
db.select().from(users)) and the impossible tags compile out. Nothing to declare, nothing to sync — the ORM emitted the type, so the evidence is verified.Retry only what's provably safe, with per-error backoff; never the deterministic errors, never the ambiguous mid-query connection loss (the write may have committed).
Fold at your handler boundary: match the tags you care about with
matchErrorPartial(the better-result fold helper), and its terminal arm handles whatever you don't fold: 500 + observability, with the compiler listing what you're ignoring.
Example
import { matchErrorPartial } from "better-result";
import type { UniqueViolation } from "db-result";
import { tryDb } from "db-result/pg"; // or /sqlite /mysql2 /mssql /d1: subpath per driver
const outcome = await tryDb(db.insert(users).values({ email }).returning());
if (outcome.isErr()) {
return matchErrorPartial(
outcome.error,
{
// handlers are annotated with the concrete tag class — the library's
// match-error idiom (the key must match the handler's `_tag`)
"db/unique-violation": (e: UniqueViolation) =>
c.json({ error: "email_taken", constraint: e.constraint }, 409),
},
(unhandled) => {
reportError(unhandled); // typed as the remainder — the tags the fold didn't claim
return c.json({ error: "internal" }, 500);
},
);
}Three forms, one retry engine:
- Builder value —
tryDb(db.select().from(users)). The builder is both the shape and the retry unit: the union narrows to what that shape provably cannot raise, and retry re-executes the builder. - Promise-returning thunk —
tryDb(() => prisma.user.findMany(args)). Full union, retry re-invokes the thunk. The form for one-shot calls (Prisma, raw SQL,client.query) that can't be re-executed. - Settled promise —
tryDb(promise). One-shot: full union, no auto-retry (dev builds warn once; wrap in a thunk to get retry).
Opt in per call site: wrap one endpoint, leave the rest throwing. Transactions:
wrap the whole db.transaction() in tryTx (whole-thunk retry). Details:
transactions.
The retry doctrine, in one breath
Deterministic failures (constraints, auth, authz, syntax, data) never retry; it's
theater. The transient set auto-retries with per-error backoff: db/deadlock
(incl. serialization 40001 and Prisma's P2034), db/lock-timeout,
db/connect-failure (connect-refused, DNS, timeout), db/query-failure for
too-many-connections / statement-timeout, plus SQLite BUSY/LOCKED.
Connection lost mid-query never auto-retries — and neither does
db/transaction-aborted: the write may have committed (or the whole tx is dead),
retrying could double it. An explicit retry config always wins;
isRetriedError(e) tells you a failure survived N attempts. If your ORM has its own
retry layer underneath (Prisma's pool acquisition), db-result's retries stack on top;
set retryTransient: false to keep only the ORM's, or disable the ORM's to keep only
ours. Details: retry.
Guards
Every tag is a class, and the per-tag check is the class's own static is —
UniqueViolation.is(e), QueryFailure.is(e) — the same idiom as
better-result's TaggedError.is. Classes are exported as values; construct
errors via tryDb, never by instantiating them.
Family guards group the tags most often folded together:
isConnectionFailure is true for either connection tag (db/connect-failure
or db/connection-lost); isConstraintViolation is true for any of the four
constraint tags (db/unique-violation, db/foreign-key-violation,
db/not-null-violation, db/check-violation) — the canonical "your input
broke a schema rule" fold. When a fold sends each constraint tag to the same
outcome, one family guard check beats four X.is arms; when the arms
diverge or read the error, matchErrorPartial(error, folds, onUnhandled) is
the fold (handlers that read the error annotate the concrete class, per
better-result's docs).
Shape-aware types: the union narrows itself
Pass the query builder itself. Its type is evidence of what the query can and cannot do, and the impossible tags compile out of the union. No declared types, no matching by hand: the ORM emitted the builder type, so the evidence is verified by construction.
import { tryDb, tryTx } from "db-result/pg";
import type { Kysely } from "kysely";
interface DB {
users: { id: number; email: string; name: string };
}
declare const db: Kysely<DB>;
// builder value: the shape IS the type — constraints are write-only
const rows = await tryDb(db.selectFrom("users").selectAll());
// ^? Result<User[], DbError minus { unique | fk | not-null | check }>
// deadlock stays (SELECT … FOR UPDATE); data-error stays (read conversions);
// transaction-aborted stays (a tx-bound select can raise 25P02)
// write builders: every constraint stays in the union
await tryDb(db.insertInto("users").values({ email }).returningAll());
// delete builder: FK is the only constraint a DELETE can hit
await tryDb(db.deleteFrom("users").where("id", "=", id));
// one-shot calls (Prisma, raw SQL): the thunk form — full union, retry on
await tryDb(() => prisma.user.create({ data: { email } })); // P2002 → db/unique-violation
// transactions: wrap the whole thing — BEGIN can fail, so the full union is honest
await tryTx(() =>
db.transaction().execute(async (tx) => {
/* … */
}),
);Then the fold terminal lists only what's left for the select shape above:
(unhandled) => {
// db/deadlock | db/lock-timeout | db/data-error | db/connect-failure |
// db/connection-lost | db/transaction-aborted | db/authentication-failed |
// db/authorization-failed | db/sql-syntax-error | db/query-failure
reportError(unhandled);
return c.json({ error: "internal" }, 500);
};Chained queries keep the shape: joins, where, orderBy aren't probe keys, so a
realistic select still narrows. A builder that proves no shape (raw SQL, Kysely's
mergeInto) is a compile error — use the thunk form rather than guessing; the
lattice never silently widens. Narrowing is structural only: no ORM imports, zero
runtime cost (the probes compile away), and the runtime classifier is never
affected — it stays honest for every shape, including the reads-that-write
footgun. Full lattice, footguns, and per-driver ledgers:
shapes.
Commit to Result shapes: the {orm}TryDb wrappers
If you want the whole codebase on Result shapes — no tryDb at every call site,
no thunks — wrap the ORM client once. One wrapper per ORM, each on the shared
core: drizzleTryDb (db-result/drizzle), kyselyTryDb (db-result/kysely).
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzleTryDb } from "db-result/drizzle";
const db = drizzleTryDb(drizzle({ connection, schema }));
const outcome = await db.select({ id: users.id }).from(users).execute();
// ^? Promise<Result<…, readUnion>> — unwrap with isOk()/isErr()
if (outcome.isErr()) return outcome; // the fold terminal lists what's left
const [user] = outcome.value;One config, everywhere: builders re-execute on retry, transaction restarts
whole, raw execute re-invokes — the wrapper owns the re-invocation, so there
are no thunks at the call site. Per ORM:
- drizzle — builder chains E-track; the union narrows per builder shape;
relational queries (
db.query.<table>.findMany/findFirst/findOne) resolveResult<T, readUnion>— the constraint tags excluded, retry applied — so the whole surface is on Result shapes; rows degrade toRecord<string, any>-shaped arrays on wrapped chains (the mapped chain can't preserve Drizzle's generics — drop totryDb(builder)where row literals matter). Pass a raw builder there:tryDbon an already E-tracked builder would wrap the Result again — keep the unwrapped handle (rawDb) around for row-exact writes.$withandrefreshMaterializedViewpass through raw. - kysely — builder chains E-track with the rows staying precise
(Kysely's chain methods return the same class parameters; the overloaded
where/set/values/join forms are re-added explicitly);with(...)CTE chains pass through raw; transactions resolve attransaction().execute(cb). The convenience terminals are E-tracked too:executeTakeFirstresolvesResult<T | undefined, E>(no row isOk(undefined), like Kysely), andexecuteTakeFirstOrThrowresolvesResult<T, E | NoResultError>— Kysely's only throw becomes a value, customerrorConstructorhonored. Shape narrowing applies to both. - prisma — no wrapper: every delegate call is one-shot (a
PrismaPromisememoizes after its firstthen, so it can never re-execute). Use the thunk form —tryDb(() => prisma.user.findMany(args))— full union, retry by re-invocation; Prisma P-codes still classify exactly.
Tighten the union per protocol with the explicit generic on any wrapper:
drizzleTryDb<typeof db, SqliteDbError>(db).
Drivers
One package, subpath entry points per protocol: db-result/pg, /sqlite, /d1,
/mysql2, /mssql. Every driver Drizzle and Kysely support maps to one of them (the
classifier reads protocol signals: SQLSTATE, SQLite codes, mysql errno, mssql number,
Prisma P-codes). Your ORM name is not an entry point: Kysely-on-Postgres imports from
db-result/pg, Drizzle-on-SQLite from db-result/sqlite. Drizzle: classification is
wrapper-transparent (the cause chain reaches the driver error regardless of version);
the narrowing probes are verified against 1.0+ (currently 1.0.0-rc.4); on ~0.9 use
the thunk form. Full map: adoption.
Docs for agents (and humans who want details)
The skill at skills/db-result/SKILL.md is the map: an
escalation ladder and a task index into references/:
overview (the idea in one page),
vocabulary (the 14 tags, per-driver
unions), retry, transactions,
patterns (upsert, idempotency keys, not-found).
The sharp edges, up front
- Rethrown, not labeled. An error that matches no known protocol shape is
rethrown as a real exception; your existing try/catch still works (inside
Result.genit surfaces as aPanic). Never taggeddb/query-failure. That includes errors you throw inside the thunk: only driver-originated failures are classified.db/query-failureis for known-but-unspecific shapes. Unknown shapes are a request for a new mapping, not a catch-all. - Not-found is data, not a tag. A missing row is
null/ your domain error, never adb/*tag. Kysely'sNoResultErrorhas no protocol shape, so it's rethrown, not labeled. Prisma'sP2025is a known-but-unspecific shape, so it lands indb/query-failure: reachable, but deliberately not tag-distinguished: preferfindFirst/findUniqueand checknull. constraintis the driver's identifier, not a normalized key; SQLite givestable.column, Postgres gives the constraint name. Match on the tag, disambiguate byconstraintinside the arm (users_email_keyvs an unexpected index), and use it for observability.- Fold arms read
_tag,constraint,potentiallyTransient, and the driver error oncause. Stripcauseat wire boundaries: better-result'stoJSON()spreads it (with stack) by design.
Verification
Runs on Node ≥ 18 and Bun (D1 targets Workers). ESM only; TypeScript ≥ 5.4. Real-driver tests run locally against a Docker suite (pg 16, mysql 8, mssql 2022) and embedded engines (bun:sqlite, node:sqlite, better-sqlite3, libsql):
bun test # everything — unit suites are colocated next to
# their module (src/**/*.test.ts); integration
# suites skip without their DSNs
docker compose up -d --wait # + the DSNs (see package.json) →
bun run test:integration # the live Docker pass onlyStatus: 0.1.0, MIT. Every release runs the full suite above first.
Classification modeled on Effect SQL,
finer on the constraint family (FK/not-null/check stay separate for the fold),
corrected where Effect falls short (masks SQLite extended codes, misses transient
53300). Extracted from result-rpc.
