Skip to content

fix(prisma)!: keep only domain outcomes in E, defect the rest - #176

Merged
btravers merged 2 commits into
mainfrom
fix/prisma-error-channel-soundness
Jul 31, 2026
Merged

fix(prisma)!: keep only domain outcomes in E, defect the rest#176
btravers merged 2 commits into
mainfrom
fix/prisma-error-channel-soundness

Conversation

@btravers

@btravers btravers commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Found while auditing packages/prisma. Started as one soundness bug, grew into
the design question underneath it.

E was carrying things nobody branches on

DriverError sat in every operation's error channel, so every call site had to
carry an arm that did exactly what the defect arm beside it did:

errCases: (matcher) => matcher
  .with(P.tag("UniqueConstraintViolation"), (e) => resp.conflict(e.fields))
  .with(P.tag("DriverError"), (e) => resp.serverError(e)),   // ← this
defect: (cause) => resp.serverError(cause),                   // ← and this

That is ceremony, not modelling. A defect isn't a crash — it flows through the
pipeline untouched and is folded at the edge like any other unmodeled failure —
so the test for E is simply would you branch on it? You genuinely handle a
duplicate email or a missing parent row. You do not write domain logic for a
severed TCP connection.

So E now holds the three P-codes that describe a domain outcome —
UniqueConstraintViolation (P2002, 409), ForeignKeyViolation (P2003, 400),
RecordNotFound (P2025/P2018, 404) — and every infrastructure failure is a
defect: dropped connections, pool timeouts, deadlocks, unmapped P-codes,
malformed queries, engine panics.

before after
tryFindMany / tryFindUnique / tryCount / … DriverError never
tryFindUniqueOrThrow / tryFindFirstOrThrow RecordNotFound | DriverError RecordNotFound
tryCreate / tryUpsert / tryUpdate varied, see below UniqueConstraintViolation | ForeignKeyViolation | RecordNotFound
tryDelete RecordNotFound | ForeignKeyViolation | DriverError ForeignKeyViolation | RecordNotFound
tryCreateMany / tryUpdateMany / *AndReturn … | DriverError UniqueConstraintViolation | ForeignKeyViolation
tryDeleteMany ForeignKeyViolation | DriverError ForeignKeyViolation
tryPaginate(...).withCursor(...) DriverError InvalidCursor

Consequences:

  • The DriverError class is removed. Nothing routes to it any more, and an
    exported error that can never appear in an E would only invite confusion.
    The original Prisma error now reaches your defect arm unwrapped, with its
    code, meta and stack intact — strictly more informative than the wrapper.
  • A read has no modeled failure. Absence is still null.
  • Pagination gains InvalidCursor, the one carve-out. A cursor is an opaque
    string from a client, so a parseCursor that rejects it — or a query Prisma
    refuses to validate — is anticipated input you answer with a 400. A throw out
    of getCursor, which reads rows we just fetched, is a bug and stays a
    defect; a CursorParseFailure sentinel is what tells the two apart. This is
    an improvement on the status quo, where the same failure hid inside
    DriverError with no name of its own.
  • Retrying P2024 / P2034 now goes through recoverDefect and inspects the
    cause, rather than matching a tag. That is one place in a codebase, versus an
    arm at every call site.
  • $tryTransaction keeps | PrismaQueryError — not defensively: a deferred
    constraint surfaces its violation at COMMIT, so the boundary itself can
    genuinely produce one.

The soundness bug that started it

A nested connect to a missing record raises P2025, but CreateError and
UpsertError excluded RecordNotFound — neither operation misses a row of its
own, so it looked impossible. Confirmed against the package's own SQLite client:

db.post.tryCreate({ data: { title: "x", author: { connect: { id: 999 } } } })
  → Err(RecordNotFound)   // cause.code === "P2025", meta.operation === "a nested connect"

The runtime therefore produced an error the type said could not occur. A
type-exhaustive mapErrCases had no arm for the value that arrived, the matcher
threw NonExhaustiveError, and the throw-to-defect net turned a modeled database
failure into a Defect:

RESULT TAG: Defect
CAUSE: NonExhaustiveError: unthrown: no pattern matched the value {"_tag":"RecordNotFound",…}

upsert hits the same thing in both branches. Both unions now carry
RecordNotFound; the batch mutations accept no nested writes, so they stay free
of it. P2018 — the same failure from the to-many side — maps to
RecordNotFound too, instead of falling through.

Also

  • after and before are now mutually exclusive. Passing both type-checked
    and silently dropped after.
  • tryPaginate runs getExtensionContext and the query inside its boundary
    thunk, matching the discipline query() already had.
  • Type-test gaps closed (tryUpdate, tryDelete, tryFindUnique, tryCount,
    the createMany pair), plus a satisfies proving
    fromPromise(p, qualifyPrismaError) infers E = PrismaQueryError with no
    Defect leak — verified to actually bite by deliberately breaking it.
  • README and Prisma guide rewritten around the new rule; #36 (#35) comment
    drift unified; CLAUDE.md and typedoc.json synced.

Breaking, shipped as a minor

qualifyPrismaError takes the injected defect helper as a second argument (it
is a qualify, so passing it to a boundary is unchanged — only direct
invocation needs updating), the DriverError class is gone, and every error
union changed. Each is a compile error at the call site rather than a silent
behaviour change.

Verification

format --check, lint, typecheck, knip, test, build and build:docs
all green. Coverage stays at 100% statements/branches/functions/lines; the suite
grows from 49 to 62 tests.

One thing deliberately not done: the prisma catalog pins look one patch
behind (7.8.0 vs 7.9.1), but pnpm install refuses the bump — the repo's
minimumReleaseAge policy holds 7.9.1 until it matures. They are held on
purpose, not stale.

A nested `connect` to a missing record raises P2025, but `CreateError` and
`UpsertError` excluded `RecordNotFound` — neither operation misses a row of its
own, so it looked impossible. The runtime therefore produced an error the type
said could not occur: a type-exhaustive `mapErrCases` had no arm for the value
that arrived, the matcher threw `NonExhaustiveError`, and the throw-to-defect
net turned a modeled database failure into a `Defect`.

Both unions now carry `RecordNotFound`, and `CreateManyError` is split out so
the batch mutations stay tight (they accept no nested writes). P2018 — the same
failure from the to-many side — maps to `RecordNotFound` too instead of falling
through to `DriverError`.

Also in this pass:

- Route Prisma's non-query errors (`PrismaClientValidationError`,
  `PrismaClientInitializationError`, `PrismaClientRustPanicError`) to the defect
  channel. They are bugs and environment faults, not anticipated outcomes, so
  `DriverError` now means what it says: the database refused the query.
  `PrismaClientUnknownRequestError` deliberately stays a `DriverError`.
  `qualifyPrismaError` becomes a real `qualify` — `(cause, defect)`, generic in
  the marker type so core's non-exported `Defect` need not be named.
- Keep one carve-out: a validation error out of `withCursor` stays a modeled
  `DriverError`. A cursor is an opaque string from a client, so garbage in is
  anticipated input, not a bug.
- Make `after` and `before` mutually exclusive in `CursorPaginationOptions`.
  Passing both type-checked and silently dropped `after`.
- Run `getExtensionContext` and the query inside `tryPaginate`'s boundary thunk,
  matching the discipline `query()` already had.
- Close the type-test gaps (`tryUpdate`, `tryDelete`, `tryFindUnique`,
  `tryCount`, the createMany pair) and assert `qualifyPrismaError` infers
  `E = PrismaQueryError` at a boundary, with no `Defect` leaking in.
- Rewrite the README's per-operation list as a table, update the Prisma guide,
  and sync CLAUDE.md.

Coverage stays at 100%; the suite grows from 49 to 57 tests.
Copilot AI review requested due to automatic review settings July 31, 2026 22:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a soundness hole in @unthrown/prisma by ensuring tryCreate / tryUpsert correctly model RecordNotFound when nested connect operations point at missing rows (Prisma P2025/P2018), preventing modeled DB failures from being silently converted into Defect via a runtime NonExhaustiveError. It also tightens pagination cursor typing and refines Prisma error triage so “bug-shaped” Prisma errors are routed to the defect channel.

Changes:

  • Widen tryCreate / tryUpsert error unions to include RecordNotFound, and map P2018 → RecordNotFound.
  • Route Prisma non-query errors (validation/initialization/panic) to the defect channel, with a pagination carve-out that keeps validation errors modeled as DriverError.
  • Make withCursor’s after/before mutually exclusive at the type level; expand type-level + runtime regression tests and update docs/changeset.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/prisma/src/index.ts Fixes per-operation error unions (incl. create/upsert), updates qualifyPrismaError to be a true (cause, defect) qualify, adds pagination-specific triage, and keeps boundary discipline via thunks.
packages/prisma/src/index.spec.ts Adds regression tests covering nested-connect RecordNotFound, exhaustive mapErrCases behavior, and defect routing for malformed queries + pagination carve-out.
packages/prisma/src/types.test-d.ts Extends type-level assertions for per-operation error channels and verifies fromPromise(..., qualifyPrismaError) infers E without Defect leakage.
packages/prisma/src/pagination.ts Enforces after/before mutual exclusivity in CursorPaginationOptions and documents the constraint.
packages/prisma/README.md Updates usage examples and documents the per-operation error table and defect-vs-driver triage.
docs/how-to/use-with-prisma.md Syncs guide content with the new error modeling, adds defect-channel routing details, and documents pagination constraints/carve-out.
packages/prisma/typedoc.json Hides the new internal CreateManyError alias from generated API docs.
CLAUDE.md Updates repository spec/documentation to reflect the corrected Prisma semantics and pagination typing.
.changeset/wild-moons-smoke.md Adds a minor release note describing the fixed unions, defect routing, and cursor option typing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

`DriverError` was in every operation's error channel, so every call site had to
carry an arm that did exactly what the `defect` arm beside it did:

    errCases: (matcher) => matcher
      .with(P.tag("UniqueConstraintViolation"), (e) => resp.conflict(e.fields))
      .with(P.tag("DriverError"), (e) => resp.serverError(e)),
    defect: (cause) => resp.serverError(cause),

That is ceremony, not modelling. `E` should hold what you branch on; a defect is
not a crash, it flows to the edge and is folded there like any other unmodeled
failure.

So `E` now carries the three P-codes that describe a domain outcome —
UniqueConstraintViolation (409), ForeignKeyViolation (400), RecordNotFound (404)
— and every infrastructure failure is a defect: dropped connections, pool
timeouts, deadlocks, unmapped P-codes, malformed queries, engine panics.

- The `DriverError` class is removed. The original Prisma error now reaches the
  `defect` arm unwrapped, with its `code`, `meta` and stack intact.
- A read has no modeled failure: `tryFindMany` is `AsyncResult<User[], never>`.
- Pagination gains `InvalidCursor`, the one carve-out — a cursor is an opaque
  string from a client, so a `parseCursor` that rejects it (or a query Prisma
  will not validate) is a 400, not a bug. A throw out of `getCursor`, which
  reads rows we just fetched, stays a defect; the `CursorParseFailure` sentinel
  is what tells the two apart.
- Retrying P2024 / P2034 now goes through `recoverDefect` rather than a tag
  match — one place in a codebase, versus an arm at every call site.

`$tryTransaction` keeps `| PrismaQueryError`: a deferred constraint surfaces its
violation at COMMIT, so the boundary itself can genuinely produce one.
@btravers btravers changed the title fix(prisma): close the tryCreate/tryUpsert error-channel hole fix(prisma)!: keep only domain outcomes in E, defect the rest Jul 31, 2026
@btravers
btravers requested a review from Copilot July 31, 2026 22:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread packages/prisma/typedoc.json
@btravers
btravers merged commit ab7194f into main Jul 31, 2026
15 checks passed
@btravers
btravers deleted the fix/prisma-error-channel-soundness branch July 31, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants