Skip to content
pre-alpha — the TypeScript API may still shift. The SQL won't.

In sqlfu, you handle database errors by kind, not by string-matching the message.

Every error from a sqlfu adapter is a SqlfuError with a .kind discriminator that has been normalized across adapters. The driver’s original error lives on .cause, untouched, for when you need to inspect anything adapter-specific.

The code that cares about a database error rarely cares which driver threw it. “That email is already in use” is a product outcome (a 409 response, or a red-squiggle in the UI). The surrounding code shouldn’t need to know that better-sqlite3 reports SQLITE_CONSTRAINT_UNIQUE, node:sqlite reports errcode: 2067, and @libsql/client wraps both behind a LibsqlError.

SqlfuError.kind closes that gap. You branch on the discriminator; sqlfu does the per-adapter mapping.

type SqlfuErrorKind =
| 'syntax' // malformed SQL
| 'missing_table' // SQLite "no such table"
| 'missing_column' // SQLite "no such column"
| 'unique_violation' // unique *or* primary-key constraint
| 'not_null_violation'
| 'foreign_key_violation'
| 'check_violation'
| 'transient' // SQLITE_BUSY / SQLITE_LOCKED families
| 'unknown' // mapper didn't recognize; inspect `.cause`

Names are SQLSTATE-aligned (matching the PostgreSQL error codes convention) so that when a postgres adapter lands, mapping from SQLSTATE becomes a direct lookup rather than a second vocabulary. missing_table and missing_column are the two deliberate deviations: SQLSTATE’s undefined_table and undefined_column collide with TypeScript’s undefined at reading time.

Primary-key violations collapse into unique_violation. From a product perspective both are “that row already exists”; code that needs to distinguish them can still read .cause.code.

class SqlfuError extends Error {
kind: SqlfuErrorKind;
query: SqlQuery; // the query that failed; includes `.name` if named
system: string; // 'sqlite' (OTel db.system value)
cause: unknown; // the original driver error, byte-identical
}
  • .message comes straight from the driver, so console.error and Sentry breadcrumbs still show the signal text ("UNIQUE constraint failed: users.email").
  • .stack is preserved from the driver error, so your call-site frame is the first useful frame: stack traces point at where the query was actually issued rather than at sqlfu internals.
  • .query stays nested as a SqlQuery (rather than flattened to .sql/.args) so handlers can still reach error.query.name for tagging, and so error.query can be passed as-is into logs or follow-up calls.
import {SqlfuError} from 'sqlfu';
try {
await client.run(createUser);
} catch (error) {
if (error instanceof SqlfuError && error.kind === 'unique_violation') {
return response.status(409).json({error: 'email already taken'});
}
throw error;
}

Because .query and .system are on the error itself, a plain error-reporter hook doesn’t need a parallel context object:

import {instrument, SqlfuError} from 'sqlfu';
const client = instrument(
baseClient,
instrument.onError(({error}) => {
if (error instanceof SqlfuError) {
Sentry.captureException(error, {
tags: {
'db.error.kind': error.kind,
'db.query.summary': error.query.name || 'sql',
'db.system': error.system,
},
});
}
}),
);

kind is a natural low-cardinality dimension for Sentry, PostHog, or DataDog: high enough to tell a constraint violation from a transient lock, low enough not to explode your tag index.

.cause holds the driver’s original error verbatim. Useful for the long tail: adapter-specific flags, nested wrapping, or debugging a kind: 'unknown'.

catch (error) {
if (error instanceof SqlfuError && error.kind === 'unknown') {
console.error('unrecognized DB error, please file an issue', error.cause);
}
}

If you see kind: 'unknown' in production, the right response is to file a bug with the driver and message. The mapper is library-owned, not per-user-configurable.

  1. Branching on .kind is stable across adapters. error.code === 'SQLITE_CONSTRAINT_UNIQUE' works for better-sqlite3, silently breaks when you switch to @libsql/client (which reports the extended code on .cause.code), and breaks again for node:sqlite (which uses a numeric errcode). error.kind === 'unique_violation' works everywhere.
  2. .query and .system let a plain catch do its job. Error reporters are the main consumer of typed errors; they need the context, and carrying it on the error itself means no QueryExecutionContext plumbing.