Errors
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 mental model
Section titled “The mental model”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.
The kinds
Section titled “The kinds”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}.messagecomes straight from the driver, soconsole.errorand Sentry breadcrumbs still show the signal text ("UNIQUE constraint failed: users.email")..stackis 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..querystays nested as aSqlQuery(rather than flattened to.sql/.args) so handlers can still reacherror.query.namefor tagging, and soerror.querycan be passed as-is into logs or follow-up calls.
Handling errors in application code
Section titled “Handling errors in application code”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;}Handling errors in a hook
Section titled “Handling errors in a hook”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.
Working with .cause
Section titled “Working with .cause”.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.
Why not rethrow the driver error?
Section titled “Why not rethrow the driver error?”- Branching on
.kindis 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 fornode:sqlite(which uses a numericerrcode).error.kind === 'unique_violation'works everywhere. .queryand.systemlet a plaincatchdo its job. Error reporters are the main consumer of typed errors; they need the context, and carrying it on the error itself means noQueryExecutionContextplumbing.
References
Section titled “References”- SQLite result codes
- PostgreSQL SQLSTATE codes (for context on the naming convention)
- observability.md: how
onErrorcomposes with OpenTelemetry, Sentry, PostHog