npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

db-result

v0.3.0

Published

Database failures as better-result tagged errors — Result<T, DbError>, retry-safe, driver-agnostic.

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-result

Built 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-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

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 valuetryDb(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 thunktryDb(() => 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 promisetryDb(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 isUniqueViolation.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) 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.

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.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):

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.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.