fix(prisma)!: keep only domain outcomes in E, defect the rest - #176
Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
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/tryUpserterror unions to includeRecordNotFound, 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’safter/beforemutually 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found while auditing
packages/prisma. Started as one soundness bug, grew intothe design question underneath it.
Ewas carrying things nobody branches onDriverErrorsat in every operation's error channel, so every call site had tocarry an arm that did exactly what the
defectarm beside it did: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
Eis simply would you branch on it? You genuinely handle aduplicate email or a missing parent row. You do not write domain logic for a
severed TCP connection.
So
Enow 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 adefect: dropped connections, pool timeouts, deadlocks, unmapped P-codes,
malformed queries, engine panics.
tryFindMany/tryFindUnique/tryCount/ …DriverErrornevertryFindUniqueOrThrow/tryFindFirstOrThrowRecordNotFound | DriverErrorRecordNotFoundtryCreate/tryUpsert/tryUpdateUniqueConstraintViolation | ForeignKeyViolation | RecordNotFoundtryDeleteRecordNotFound | ForeignKeyViolation | DriverErrorForeignKeyViolation | RecordNotFoundtryCreateMany/tryUpdateMany/*AndReturn… | DriverErrorUniqueConstraintViolation | ForeignKeyViolationtryDeleteManyForeignKeyViolation | DriverErrorForeignKeyViolationtryPaginate(...).withCursor(...)DriverErrorInvalidCursorConsequences:
DriverErrorclass is removed. Nothing routes to it any more, and anexported error that can never appear in an
Ewould only invite confusion.The original Prisma error now reaches your
defectarm unwrapped, with itscode,metaand stack intact — strictly more informative than the wrapper.null.InvalidCursor, the one carve-out. A cursor is an opaquestring from a client, so a
parseCursorthat rejects it — or a query Prismarefuses 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 adefect; a
CursorParseFailuresentinel is what tells the two apart. This isan improvement on the status quo, where the same failure hid inside
DriverErrorwith no name of its own.recoverDefectand inspects thecause, rather than matching a tag. That is one place in a codebase, versus an
arm at every call site.
$tryTransactionkeeps| PrismaQueryError— not defensively: a deferredconstraint surfaces its violation at COMMIT, so the boundary itself can
genuinely produce one.
The soundness bug that started it
A nested
connectto a missing record raises P2025, butCreateErrorandUpsertErrorexcludedRecordNotFound— neither operation misses a row of itsown, so it looked impossible. Confirmed against the package's own SQLite client:
The runtime therefore produced an error the type said could not occur. A
type-exhaustive
mapErrCaseshad no arm for the value that arrived, the matcherthrew
NonExhaustiveError, and the throw-to-defect net turned a modeled databasefailure into a
Defect:upserthits the same thing in both branches. Both unions now carryRecordNotFound; the batch mutations accept no nested writes, so they stay freeof it. P2018 — the same failure from the to-many side — maps to
RecordNotFoundtoo, instead of falling through.Also
afterandbeforeare now mutually exclusive. Passing both type-checkedand silently dropped
after.tryPaginaterunsgetExtensionContextand the query inside its boundarythunk, matching the discipline
query()already had.tryUpdate,tryDelete,tryFindUnique,tryCount,the createMany pair), plus a
satisfiesprovingfromPromise(p, qualifyPrismaError)infersE = PrismaQueryErrorwith noDefectleak — verified to actually bite by deliberately breaking it.#36 (#35)commentdrift unified;
CLAUDE.mdandtypedoc.jsonsynced.Breaking, shipped as a minor
qualifyPrismaErrortakes the injecteddefecthelper as a second argument (itis a
qualify, so passing it to a boundary is unchanged — only directinvocation needs updating), the
DriverErrorclass is gone, and every errorunion changed. Each is a compile error at the call site rather than a silent
behaviour change.
Verification
format --check,lint,typecheck,knip,test,buildandbuild:docsall green. Coverage stays at 100% statements/branches/functions/lines; the suite
grows from 49 to 62 tests.
One thing deliberately not done: the
prismacatalog pins look one patchbehind (7.8.0 vs 7.9.1), but
pnpm installrefuses the bump — the repo'sminimumReleaseAgepolicy holds 7.9.1 until it matures. They are held onpurpose, not stale.