Typed Errors

Turbine throws typed errors you can catch with instanceof. Every error extends TurbineError and carries a stable readonly code field you can use for programmatic handling. Human-readable .message strings also start with the same code tag (e.g. [TURBINE_E008] …) so logs are greppable without structured field access, but do not parse the message; branch on err.code / instanceof. Postgres driver errors (23505, 23503, 40P01, etc.) are translated into typed classes automatically via wrapPgError().

Since 0.65 every error also carries readonly docsUrl (e.g. https://turbineorm.dev/errors#e008, an anchor into the table below) and its message ends with the same link, so a raw log line is one click from its explanation and structured sinks get the URL as a field without parsing anything. A unit test asserts every code has its row and anchor here, so a new code cannot ship undocumented.

Error code table#

CodeClassWhen thrown
TURBINE_E001NotFoundErrorfindUniqueOrThrow, findFirstOrThrow, update/delete against a non-matching row
TURBINE_E002TimeoutErrorQuery or transaction exceeds its configured timeout
TURBINE_E003ValidationErrorUnknown column, invalid operator, empty-where guard on update/delete, PII aggregate without includePii, a privilege option passed a plain true instead of the UNSAFE symbol, a createMany whose first data row names no column while a later row does
TURBINE_E004ConnectionErrorPool connection failure
TURBINE_E005RelationErrorUnknown relation name in a with clause
TURBINE_E006MigrationErrorChecksum mismatch, migration-lock contention, or a failure while applying a migration
TURBINE_E007CircularRelationErrorRelation nesting depth exceeds 10
TURBINE_E008UniqueConstraintErrorpg 23505, translated via wrapPgError()
TURBINE_E009ForeignKeyErrorpg 23503, translated via wrapPgError()
TURBINE_E010NotNullViolationErrorpg 23502, translated via wrapPgError()
TURBINE_E011CheckConstraintErrorpg 23514, translated via wrapPgError()
TURBINE_E012DeadlockErrorpg 40P01, retryable, exposes isRetryable: true
TURBINE_E013SerializationFailureErrorpg 40001, retryable, exposes isRetryable: true
TURBINE_E014PipelineErrorNon-transactional pipeline has partial failures
TURBINE_E015OptimisticLockErrorVersion mismatch on an optimisticLock update, the row was modified by another transaction
TURBINE_E016ExclusionConstraintErrorpg 23P01, translated via wrapPgError()
TURBINE_E017UnsupportedFeatureErrorA Postgres-only feature (pgvector, LISTEN/NOTIFY, RLS sessionContext) invoked on a non-Postgres engine, exposes feature and dialect
TURBINE_E018ReadOnlyErrorA write refused because the database is read-only, exposes reason: 'snapshot' | 'rbac' (PowDB snapshot serving / read-only role)

Catching typed errors#

Import the classes from the package root and narrow with instanceof.

import {
  NotFoundError,
  ValidationError,
  TimeoutError,
  UniqueConstraintError,
} from 'turbine-orm';
 
try {
  const user = await db.users.findUniqueOrThrow({ where: { id: 999 } });
} catch (err) {
  if (err instanceof NotFoundError) {
    // err.code === 'TURBINE_E001'
    // err.table, err.where, and err.operation are all populated
    return { status: 404 };
  }
  if (err instanceof ValidationError) {
    // err.code === 'TURBINE_E003'
    return { status: 400, message: err.message };
  }
  if (err instanceof TimeoutError) {
    // err.code === 'TURBINE_E002'
    // err.timeoutMs is the configured limit
    return { status: 504 };
  }
  throw err;
}

Unknown field names#

Every name you supply is resolved against the schema, and one that does not resolve throws TURBINE_E003 naming the table and, where it can, the column you probably meant. That holds for where, orderBy, distinct, groupBy keys, aggregate targets, create/update data, and (since 0.64) select and omit at every depth, including inside a relation. There is no partial mode: a name is never dropped and the query never runs without it.

Naming a relation in select or omit gets its own message, because it is usually a habit carried over from Prisma rather than a typo:

[turbine] "comments" is a relation on table "post", not a column, so it cannot
be named in `select`. Load it with `with: { comments: true }`, which is a
sibling of `select`, not a member of it.

Relations live in with, which sits beside select rather than inside it. To narrow a relation's own columns, put a select inside that relation's options: with: { comments: { select: { body: true } } }.

wrapPgError translation#

Every query execution runs its pg error through wrapPgError(). The original driver error is preserved as .cause on the wrapped error.

pg SQLSTATENameTurbine class
23505unique_violationUniqueConstraintError
23503foreign_key_violationForeignKeyError
23502not_null_violationNotNullViolationError
23514check_violationCheckConstraintError
23P01exclusion_violationExclusionConstraintError
40P01deadlock_detectedDeadlockError (retryable)
40001serialization_failureSerializationFailureError (retryable)

Other pg errors pass through unchanged.

import { UniqueConstraintError } from 'turbine-orm';
 
try {
  await db.users.create({ data: { email: 'taken@example.com', ... } });
} catch (err) {
  if (err instanceof UniqueConstraintError) {
    // err.constraint, e.g. 'users_email_key'
    // err.columns  , e.g. ['email']
    // err.table    , e.g. 'users'
    // err.cause    , the original pg error
    return { status: 409, message: 'Email already in use' };
  }
  throw err;
}

Retryable errors#

DeadlockError and SerializationFailureError expose a readonly isRetryable = true as const field. This isn't just a comment. It's a type-level signal so your retry loop narrows correctly.

You do not have to write that loop. Turbine exports withRetry, and TurbineClient exposes the same thing as db.$retry:

import { withRetry } from 'turbine-orm';
 
await withRetry(() =>
  db.$transaction(
    async (tx) => {
      const row = await tx.counters.findUniqueOrThrow({ where: { id: 1 } });
      await tx.counters.update({
        where: { id: 1 },
        data: { value: row.value + 1 },
      });
    },
    { isolationLevel: 'Serializable' },
  ),
);

withRetry(fn, options?) retries only when the thrown error carries isRetryable === true, and rethrows anything else immediately. Options: maxAttempts (default 3), baseDelay in ms (default 50), maxDelay (default 5000), and an onRetry(error, attempt) hook. Backoff is exponential with jitter, capped at maxDelay. db.$retry(fn, options) is the same function bound to the client, for when you would rather not add an import.

Neither Prisma nor Drizzle surfaces a comparable typed retry signal, Prisma uses the stringly-typed code: 'P2034' on a generic PrismaClientKnownRequestError, and Drizzle bubbles up raw pg errors for you to grep SQL states from.

Pipeline errors#

PipelineError is only thrown when a pipeline runs in non-transactional mode ({ transactional: false }) and one or more queries fail. It carries a per-query result array so you can inspect exactly which succeeded and which failed.

import { PipelineError } from 'turbine-orm';
 
try {
  await db.pipeline(queries, { transactional: false });
} catch (err) {
  if (err instanceof PipelineError) {
    // err.results: ({status:'ok', value} | {status:'error', error})[]
    // err.failedIndex: zero-based index of the first failed query
    // err.failedTag: DeferredQuery.tag of the first failure
    for (const [i, slot] of err.results.entries()) {
      if (slot.status === 'error') {
        console.error(`Query ${i} failed:`, slot.error);
      }
    }
  }
}

Transactional pipelines (the default) either fully succeed or roll back, a failure surfaces as whichever typed error the failing query raised.

Unsupported features on other engines#

UnsupportedFeatureError (TURBINE_E017) is thrown when you invoke a Postgres-only feature on one of Turbine's other database engines, SQLite, MySQL, or SQL Server. It is not a wrapPgError() translation; Turbine raises it directly (from a capability flag on the active dialect) instead of generating broken SQL. The Postgres-only features are pgvector distance ops, LISTEN/NOTIFY realtime ($listen / $notify), and RLS sessionContext ($withSession).

import { UnsupportedFeatureError } from 'turbine-orm';
import { turbineMysql } from 'turbine-orm/mysql';
 
const db = await turbineMysql(process.env.MYSQL_URL!, SCHEMA);
 
try {
  await db.$listen('orders', (payload) => console.log(payload));
} catch (err) {
  if (err instanceof UnsupportedFeatureError) {
    // err.code    === 'TURBINE_E017'
    // err.feature === 'LISTEN/NOTIFY'
    // err.dialect === 'mysql'
  }
}

These particular features (pgvector, LISTEN/NOTIFY, RLS sessionContext) never throw on PostgreSQL. TURBINE_E017 is not a "wrong engine" error in general, though, and reading it that way will send you looking in the wrong place. It is the code for this build cannot do that, and Postgres raises it too:

  • relationLoadStrategy: 'batched' on a composite-key relation, including a composite-key many-to-many junction. The batched loader keys on one column, so it refuses instead of loading a wrong set. Use the default join plan for those relations.
  • relationLoadStrategy: 'batched' where the correlation key is missing from every parent row (since 0.63). The batched loader stitches in JS, so it needs that column in the rows it was handed; if a projection removed it, the relation would come back empty rather than wrong-looking, which is the worse of the two. It refuses instead, naming the relation and the key. This should not be reachable, and if you hit it, it is a bug in Turbine worth reporting; relationLoadStrategy: 'join' on that query is the workaround in the meantime.
  • On turbine-orm/prisma-compat, a Prisma shape with no Turbine equivalent: a negative take, skip on a nested include, the inclusive-cursor shapes that cannot be translated exactly, limit on updateMany / deleteMany, and $extends with a query or result component (or any component the adapter does not implement, refused at $extends time with the alternative named).

Catch on err.code === 'TURBINE_E017' and read err.feature, which names what was refused, rather than inferring the cause from the engine. See Database Engines for the full capability matrix.

The same error carries PowDB's version-gated capabilities, where the feature exists but the connected engine is too old for it. Those messages name the feature once and then give the floor and what the connection reported:

[turbine] JSON path filters is unsupported on "PowDB". Requires PowDB >= 0.12;
this connection reports 0.11.0. Upgrade powdb-server / @zvndev/powdb-embedded
(or pass `assumeEngineVersion` if the version cannot be detected).

Some gates append one more sentence naming a workaround. err.feature is the label from the first sentence, err.dialect is 'PowDB'.

Read-only refusals#

ReadOnlyError (TURBINE_E018, since v0.35) is the routing signal for writes that hit a read-only database. Its reason field tells you which kind:

  • 'snapshot', the database itself is read-only: a PowDB --readonly snapshot server, an embedded { readonly: true } open, or Turbine's own client-level readonly: true fail-fast flag. Nothing can write here; route the write to your primary.
  • 'rbac', the database is writable but this connection's role may not write (permission denied: role …). Re-authenticating with a writer role may suffice.
import { ReadOnlyError } from 'turbine-orm';
 
try {
  await replica.orders.create({ data });
} catch (err) {
  if (err instanceof ReadOnlyError && err.reason === 'snapshot') {
    await primary.orders.create({ data }); // route to the writer
  }
}

See Read Replicas for the snapshot-serving pattern this supports.

Safe error messages#

By default, NotFoundError messages include only the keys of the where clause, values are redacted so PII doesn't leak into logs (Sentry, Datadog, and friends).

[turbine] findUniqueOrThrow on "users" found no record matching where: { id, email }

The full where object is always available as err.where for programmatic access, only the human-readable message is redacted. Opt into verbose messages (useful in local development) by setting errorMessages: 'verbose' in your TurbineConfig, or by calling setErrorMessageMode('verbose') at startup.

const db = turbine({
  connectionString: process.env.DATABASE_URL,
  errorMessages: process.env.NODE_ENV === 'development' ? 'verbose' : 'safe',
});

See also#