# 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.
```sh
bun add better-result db-result
```
Built on [better-result](https://github.com/dmmulroy/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):
```ts
// 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-failure
```
`unique-violation`, `foreign-key-violation`, `not-null-violation` and
`check-violation` each carry the driver's `constraint` identifier.
`connect-failure` means the channel was never established (DNS, refused,
timeout) — safe to retry; `connection-lost` means it died mid-query —
ambiguous, never auto-retried. That split is deliberate: connect-phase vs
mid-query. `data-error` is a value problem (too long, overflow, bad input);
`deadlock` includes serialization failures (`40001`); `lock-timeout` includes
SQLite `BUSY`/`LOCKED`; `transaction-aborted` (`25P02`) means the whole
transaction is dead; `query-failure` is the known-but-unspecific catch-all.
No `instanceof`, no `error.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
```ts
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](./skills/db-result/references/transactions.md).
## 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](./skills/db-result/references/retry.md).
## 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.
```ts
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:
```ts
(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](./skills/db-result/references/shapes.md).
## 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`).
```ts
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`) resolve
`Result<T, readUnion>` — the constraint tags excluded, retry applied —
so the whole surface is on Result shapes; rows degrade to
`Record<string, any>`-shaped arrays on wrapped chains (the mapped chain
can't preserve Drizzle's generics — drop to `tryDb(builder)` where row
literals matter). Pass a **raw** builder there: `tryDb` on an already
E-tracked builder would wrap the Result again — keep the unwrapped handle
(`rawDb`) around for row-exact writes. `$with` and `refreshMaterializedView`
pass 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 at `transaction().execute(cb)`.
The convenience terminals are E-tracked too: `executeTakeFirst` resolves
`Result<T | undefined, E>` (no row is `Ok(undefined)`, like Kysely), and
`executeTakeFirstOrThrow` resolves `Result<T, E | NoResultError>` — Kysely's
only throw becomes a value, custom `errorConstructor` honored. Shape
narrowing applies to both.
- **prisma** — no wrapper: every delegate call is one-shot (a `PrismaPromise`
memoizes after its first `then`, 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](./skills/db-result/references/adoption.md).
## Docs for agents (and humans who want details)
The skill at [`skills/db-result/SKILL.md`](skills/db-result/SKILL.md) is the map: an
escalation ladder and a task index into [`references/`](skills/db-result/references/):
[overview](skills/db-result/references/overview.md) (the idea in one page),
[vocabulary](skills/db-result/references/vocabulary.md) (the 14 tags, per-driver
unions), [retry](skills/db-result/references/retry.md), [transactions](skills/db-result/references/transactions.md),
[patterns](skills/db-result/references/patterns.md) (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.gen` it surfaces as a `Panic`). Never tagged `db/query-failure`. That
includes errors you throw inside the thunk: only driver-originated failures are
classified. `db/query-failure` is 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 a `db/*` tag. Kysely's `NoResultError` has no protocol shape, so it's
rethrown, not labeled. Prisma's `P2025` is a known-but-unspecific shape, so it
lands in `db/query-failure`: reachable, but deliberately not
tag-distinguished: prefer `findFirst`/`findUnique` and check `null`.
- **`constraint` is the driver's identifier**, not a normalized key; SQLite gives
`table.column`, Postgres gives the constraint name. Match on the tag, disambiguate
by `constraint` inside the arm (`users_email_key` vs an unexpected index), and use
it for observability.
- **Fold arms read `_tag`, `constraint`, `potentiallyTransient`, and the driver
error on `cause`.** Strip `cause` at wire boundaries: better-result's `toJSON()`
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):
```sh
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 only
```
Status: `0.3.2`, MIT. Every release runs the full suite above first.
Classification modeled on [Effect SQL](https://github.com/Effect-TS/effect/blob/main/packages/effect/src/unstable/sql/SqlError.ts),
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](https://github.com/jokull/result-rpc/blob/main/src/db.ts).