Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8b9cf05
feat(drizzle): scaffold @unthrown/drizzle package
btravers Aug 4, 2026
1ec89ce
feat(drizzle): add tagged constraint errors and qualifyPgError
btravers Aug 4, 2026
45212b5
test(drizzle): add PGlite wire-protocol test harness
btravers Aug 4, 2026
bd34c43
fix(drizzle): defend partial-startup cleanup in the test harness
btravers Aug 4, 2026
e0ef008
feat(drizzle): add AsyncResult prepared query and abstract session
btravers Aug 4, 2026
66aab10
test(drizzle): cover UnthrownPgSession execute, arrays and objects
btravers Aug 4, 2026
31160fe
feat(drizzle): add AsyncResult builder tree and UnthrownPgDatabase
btravers Aug 4, 2026
d390d2e
fix(drizzle): route query-compilation throws to the defect channel
btravers Aug 4, 2026
82805ad
fix(drizzle): compile raw-SQL session queries inside the boundary
btravers Aug 4, 2026
c88d710
feat(drizzle): add node-postgres session with Result transactions
btravers Aug 4, 2026
7202bc5
fix(drizzle): pin pooled transactions to the checked-out connection
btravers Aug 4, 2026
e644722
feat(drizzle): add node-postgres drizzle() factory and public exports
btravers Aug 4, 2026
a86f2aa
fix(drizzle)!: give reads E = never, enforced at runtime, and drop th…
btravers Aug 4, 2026
582320a
fix(drizzle): route a prepared read through the defect boundary too
btravers Aug 4, 2026
4edb73c
test(drizzle): cover SQLSTATE mapping, defect routing and transactions
btravers Aug 4, 2026
bb63cbc
test(drizzle): make the rollback assertions able to see an empty read
btravers Aug 4, 2026
d9d3888
test(drizzle): run the suite against a real PostgreSQL via testcontai…
btravers Aug 4, 2026
9de88fe
test(drizzle): restore the range-and-gist EXCLUDE constraint
btravers Aug 4, 2026
df37e87
test(drizzle): stop a failing admin close from orphaning a database
btravers Aug 4, 2026
1b278a9
test(drizzle): assert the dropped-database cause, fix two stale comments
btravers Aug 4, 2026
78e3ce3
test(drizzle): add type-level assertions for per-operation error chan…
btravers Aug 4, 2026
20ea874
docs(drizzle): correct the preparable-read-path count in the type tests
btravers Aug 4, 2026
f5ced43
test(drizzle): pin the success channel alongside every err-only read …
btravers Aug 4, 2026
ffc1771
docs(drizzle): add the how-to page, wire the API reference, update CL…
btravers Aug 4, 2026
a66660e
docs(drizzle): correct the savepoint scheme in CLAUDE.md and tighten …
btravers Aug 4, 2026
222b028
fix(drizzle): apply the final whole-branch review wave
btravers Aug 4, 2026
bbfaa0d
fix: lift security overrides past the widened advisories and correct …
btravers Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/great-hoops-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@unthrown/drizzle": minor
---

Add `@unthrown/drizzle`, a Drizzle ORM Postgres integration that **replaces** the
stock `drizzle-orm/node-postgres` database rather than wrapping one: every method
already speaks `AsyncResult`, so there is no `try*` prefix to learn and migrating
a call site is an import change. Writes (`insert`, `update`, `delete`, raw
`db.execute`, `transaction`) carry the five integrity-constraint
SQLSTATEs as tagged errors — `UniqueConstraintViolation` (23505),
`ForeignKeyViolation` (23503), `NotNullViolation` (23502, carrying `column`),
`CheckViolation` (23514), `ExclusionViolation` (23P01) — while every
infrastructure failure (deadlock, serialization failure, statement timeout,
connection loss, a syntax error) is a `Defect`, so a retry lives in one
`recoverDefect` wrapper instead of an arm at every call site. Reads (`select`,
`$count`, `db.query.*`, `refreshMaterializedView`, and their prepared forms)
infer `E = never`, enforced at runtime through `fromSafePromise` so the type
cannot drift from what the runtime produces. Transactions follow the variants
directly: `Ok` commits, `Err` and `Defect` both roll back, an `Err` re-surfaces
typed, and there is deliberately no `tx.rollback()` — nesting is a savepoint.
Also exports `qualifyPgError` for boundaries of your own, and keeps `db.$client`
as the escape hatch to a stock drizzle database over the same pool.

Every driver rejection is wrapped in drizzle's own `DrizzleQueryError` before
triage, exactly as stock drizzle does, so a defect names the failing statement
and its params — node-postgres' `DatabaseError` carries `code`/`constraint`/
`table`/`column`/`detail` but not the SQL. The driver error stays one `cause`
level down, which is where `qualifyPgError` already reads the SQLSTATE from.

The exported pg-core classes are spelled uniformly `PgUnthrown*`
(`PgUnthrownSelectBase`, `PgUnthrownInsertBase`, …, plus `PgUnthrownDatabase`,
`PgUnthrownSession`, `PgUnthrownPreparedQuery` and
`PgUnthrownSafePreparedQuery`), mirroring drizzle's own uniform `PgAsync*`
tree; the node-postgres ones keep drizzle's `NodePg…` shape as
`NodePgUnthrown*`. One concept, one name — settled before the first publish,
since it is public API.

`drizzle-orm` and `pg` are peer dependencies (`^1.0.0-rc` and `^8.16.0`); the
internals — which subclass drizzle's `pg-core` builder tree — were verified
against `drizzle-orm@1.0.0-rc.4`.
117 changes: 116 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,105 @@ AsyncResult<infer T, …>` — structural inference over the whole method surfac
group** — its majors track `@prisma/client`'s cadence, not the family's.
`engines: { node: ">=20.19" }` (Prisma 7's floor), the one exception to the
family's `>=20`. Documented in the Prisma guide page.)
- `packages/drizzle` → `@unthrown/drizzle` (peerDeps `drizzle-orm` `^1.0.0-rc`
and `pg` `^8.16.0` — a **range, not a pin**: the range names the published
contract a consumer must satisfy, and the internals were verified against
`1.0.0-rc.4`, which the changeset records. Slaving the peer to an exact rc
would force a lockstep release on every upstream rc for a change that touched
nothing. **Deliberately outside the fixed version group** — its majors track
drizzle's cadence, not the family's. This package **replaces** the stock
`drizzle-orm/node-postgres` database rather than wrapping one: every method
already speaks `AsyncResult`, so there is no `try*` prefix (the Prisma
extension's shape does not carry over — an extension is additive, a database
is not), and migrating a call site is an import change. The factory takes
**only drizzle's own call forms** — `drizzle(connectionString)`,
`drizzle(connectionString, config)`, `drizzle({ client })`,
`drizzle({ connection })` — with deliberately **no positional-client form**,
since a second spelling of `{ client: pool }` would mean a call site no longer
ports back by changing the import.
**Five** SQLSTATEs are modeled, and they are the integrity-constraint codes
only: `UniqueConstraintViolation` 23505 (409), `ForeignKeyViolation` 23503
(400), `NotNullViolation` 23502 (400 — carries **`column`**, not
`constraint`: 23502 names the offending column and has no constraint name of
its own), `CheckViolation` 23514, `ExclusionViolation` 23P01. Everything else
is a **`Defect`**: deadlock (40P01), serialization failure (40001), statement
timeout (57014), too-many-connections (53300), syntax errors, connection
loss, a non-Postgres cause. Same rule as `@unthrown/prisma` — "would you
branch on it?" — and the same payoff: a retry wrapper for 40001/40P01 is one
`recoverDefect` inspecting the cause, not an arm at every write call site.
**Reads infer `E = never`, enforced at runtime, not merely typed**: `select`,
`$count`, `db.query.*` and `refreshMaterializedView` route through
`fromSafePromise` (via `runSafeQuery`), so a `23xxx` reaching a read path —
narrowly reachable, via a `SELECT` calling a volatile function that writes —
becomes a `Defect` rather than an `Err` the type says cannot exist. That is
the trap `@unthrown/prisma` shipped in the other direction, and the runtime
half is what stops the declaration from lying; all three routes into a read
agree, `prepare(name).execute()` included (reads hand back
`PgUnthrownSafePreparedQuery`). `refreshMaterializedView` is a read **by
explicit decision** even though `REFRESH … CONCURRENTLY` can raise a real
23505 against the view's unique index — a duplicate-producing matview is a bug
in the view definition, not a domain outcome. Writes (`insert`, `update`,
`delete`, ``db.execute(sql`…`)``, `transaction`) carry the **whole**
`PgQueryError` union, unnarrowed: a `delete` still raises 23505 through an
`ON DELETE SET DEFAULT`. **Transactions**: `Ok` commits; `Err` **and**
`Defect` both roll back, and an `Err` re-surfaces typed — so there is
deliberately **no `tx.rollback()`**, because rollback _is_ returning an `Err`
(drizzle needs one only because its rollback signal is a throw). `E` unions
`PgQueryError` **whatever the callback's own channel**, since a `DEFERRABLE`
constraint is checked at `COMMIT` and the commit can raise 23505 on its own
account; nesting is a savepoint, named from a counter **shared by every
handle descended from one transaction** and claimed before anything is
issued — **not** by nesting depth, which is drizzle's scheme and collides:
two nested transactions started concurrently (which `allAsync` makes easy to
write) would both be `sp1` on the one connection, and the first
`rollback to savepoint sp1` would unwind the other's work. A callback that
hands back something that is **not a `Result`** at all — reachable only from
JS or a cast, the `async (tx) => { await tx.insert(…) }` that forgot its
`return` — takes the **undo** path too and surfaces as a `Defect` (core's
out-of-contract rule): `isOk`/`isErr` read `.tag`, which _throws_ on
`null`/`undefined`, and that TypeError used to escape before any `ROLLBACK`
was issued, releasing the pooled client with `BEGIN` still open so the next
borrower ran inside a stale transaction. A config that renders **no clauses**
(`{}`) omits them entirely rather than interpolating an empty string —
`begin ` and `set transaction ` are both syntax errors, and `setTransaction({})`
issues no statement at all. When an undo **itself** fails it takes over the
outcome as an `AggregateError`, ordered `[thrown, original]` — core's
failure-observer convention. Every driver rejection is wrapped in drizzle's
own `DrizzleQueryError` first, exactly as `PgAsyncPreparedQuery.execute`
does, so a defect names the failing **statement and params** (node-postgres'
`DatabaseError` carries `code`/`constraint`/`table`/`column`/`detail` but not
the SQL); triage is unaffected because `qualifyPgError` already reads the
SQLSTATE through one `cause` level. A query
builder is a **thenable**, not an `AsyncResult` — `await` it into a `Result`,
or end the chain in `.execute()` to reach the combinators; compilation runs
_inside_ the boundary (`prepare` is a thunk), so a `getSQL()` throw is a
defect rather than a rejection escaping a caller with no `try`/`catch`.
It **subclasses drizzle's `@internal` pg-core APIs** (`PgSelectBase`,
`PgInsertBase`, the HKT encodings, `PgSession`/`PgPreparedQuery`) rather than
wrapping the public surface, because drizzle's builders are what carry the
select-shape inference: reimplementing them would mean reimplementing
`.from().where().leftJoin().returning()`'s type-level plumbing, and wrapping
the finished promise would put the qualification boundary _after_ the
compilation throw. The cost is a real coupling to unpublished internals — the
reason the peer range stays broad and the integration suite is not optional.
`qualifyPgError` **is** a `qualify` — `(cause, defect)`, generic in the marker
type — so it drops into a `fromPromise` at a boundary of your own; `db.$client`
is the escape hatch (a stock `drizzle-orm/node-postgres` db over the same
`Pool` is one line). Tested against a **real PostgreSQL**
(`postgres:18.4-alpine`, pinned to an exact patch) started once per run by
`@testcontainers/postgresql` in a vitest `globalSetup`, with a fresh
`CREATE DATABASE` per fixture — so **this package's suite requires a running
Docker daemon**, the one departure from the monorepo's self-contained-suite
convention (`@unthrown/prisma` deliberately uses in-memory SQLite for exactly
that reason). It is justified because the behaviour under test _is_
PostgreSQL's SQLSTATE reporting, constraint naming and transaction semantics.
Hosted GitHub runners (`ubuntu-latest`, which every job in the shared
`ci-reusable.yml` uses directly, with no job `container:`) ship Docker, so CI
needs no configuration; a future move to a containerized job would need DinD
plus `TESTCONTAINERS_HOST_OVERRIDE`. The prose samples in the README, the
how-to page and `pg-core/db.ts`'s `@example` blocks are **compiled** by
`src/docs-examples.test-d.ts` — the drizzle-side sibling of core's
`doc-examples.spec.ts`. Documented in the Drizzle guide page.)
- `packages/orpc` → `@unthrown/orpc` (peerDeps `@orpc/client` + `@orpc/server`
at `^2.0.0-beta` — **peers, not deps**: `isInferableError` is an
`instanceof ORPCError` check, the same dual-copy hazard as `isResult`;
Expand Down Expand Up @@ -867,7 +966,7 @@ code: "NOT_FOUND" }, …))`); non-inferable →

Core has **no runtime dependencies** (the error matcher is built-in). Never
pull `vitest` or any interop peer (`effect`, `neverthrow`, `@bloodyowl/boxed`,
`@orpc/*`) into core.
`@orpc/*`, `drizzle-orm`, `pg`) into core.

Every satellite package depends on core via `workspace:^` (an exact pin would
create a dual-copy hazard with the `instanceof`-based `isResult`); for the same
Expand Down Expand Up @@ -999,6 +1098,16 @@ configured outside the repo).
onRejected)`, so the fixture records the handler _and invokes it_, proving both
that it was installed and that it swallows the rejection, in one microtask
with no timer.
- **`@unthrown/drizzle` is the one suite that needs Docker.** Every other
package's tests are self-contained (`@unthrown/prisma` runs against in-memory
SQLite for exactly that reason), but drizzle's assert PostgreSQL's own
SQLSTATE reporting, constraint naming and transaction semantics, which a fake
can only pin our assumptions about. It starts one pinned
`postgres:18.4-alpine` per run via `@testcontainers/postgresql` in a vitest
`globalSetup`, so **`pnpm test` needs a running Docker daemon** locally. The
requirement is stated in the package README, the Drizzle guide page and the
`packages/drizzle` bullet above. CI needs no configuration: every job in the
shared `ci-reusable.yml` runs on `ubuntu-latest` directly, which ships Docker.
- **TSDoc `@example` blocks are compiled** (`doc-examples.spec.ts`): every
```ts fence under an `@example` is extracted, given an import preamble of the
whole public surface, and typechecked. Examples are the primary teaching
Expand All @@ -1007,6 +1116,12 @@ onRejected)`, so the fixture records the handler _and invokes it_, proving both
surface as TS2304/7006/18046 and are ignored; everything else fails, and a
renamed export fails on the _preamble import_ rather than as an ignorable
TS2304. `@unthrown/prisma`'s 34 examples are the obvious next application.
`@unthrown/drizzle` takes the same idea from the other end:
`src/docs-examples.test-d.ts` is a type-level file holding every sample its
README, its guide page and its `@example` blocks ship, so a sample that stops
compiling fails the gate. (It caught two live defects when it was written — a
README `.mapErrCases` hung off a query _builder_, which is a thenable with no
such method, and a `^?` annotation on the wrong expression.)
- Tests: Vitest. Every load-bearing invariant above gets an explicit test
(`invariants.spec.ts` guards them 1:1); core holds 100% line/function coverage,
enforced by thresholds in its `vitest.config.ts`.
Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,18 @@ defect, so the edge of your program needs a single `match` and no `try`/`catch`.

## Packages

| Package | Description |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`unthrown`](./packages/core) | The core `Result` / `AsyncResult`, interop, `TaggedError`, built-in exhaustive error matching. |
| [`@unthrown/vitest`](./packages/vitest) | Vitest matchers: `toBeOk`, `toBeOkWith`, `toBeErr`, `toBeErrWith`, `toBeErrTagged`, `toBeDefect`. |
| [`@unthrown/effect`](./packages/effect) | Effect interop: `Result ↔ Exit` (bijection), `Either`, `Effect`. |
| [`@unthrown/neverthrow`](./packages/neverthrow) | neverthrow interop: `Result ↔ Result`, `AsyncResult ↔ ResultAsync`. |
| [`@unthrown/boxed`](./packages/boxed) | Boxed interop: `Result ↔ Result`, `AsyncResult ↔ Future<Result>`. |
| [`@unthrown/prisma`](./packages/prisma) | Prisma Client extension: `try*` query methods returning `AsyncResult`, per-operation errors. |
| [`@unthrown/orpc`](./packages/orpc) | oRPC (v2) bridge: `Result`-returning handlers, `AsyncResult` client, typed errors end-to-end. |
| [`@unthrown/standard-schema`](./packages/standard-schema) | `fromSchema` / `fromSchemaAsync`: any Standard Schema validator into a `Result`. |
| [`@unthrown/oxlint`](./packages/oxlint) | oxlint plugin: `no-ambiguous-error-type`, `no-catch-all-pattern`, `no-unhandled-result`, `no-unused-matcher`, `prefer-async-result`, `no-throw`. |
| Package | Description |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`unthrown`](./packages/core) | The core `Result` / `AsyncResult`, interop, `TaggedError`, built-in exhaustive error matching. |
| [`@unthrown/vitest`](./packages/vitest) | Vitest matchers: `toBeOk`, `toBeOkWith`, `toBeErr`, `toBeErrWith`, `toBeErrTagged`, `toBeDefect`. |
| [`@unthrown/effect`](./packages/effect) | Effect interop: `Result ↔ Exit` (bijection), `Either`, `Effect`. |
| [`@unthrown/neverthrow`](./packages/neverthrow) | neverthrow interop: `Result ↔ Result`, `AsyncResult ↔ ResultAsync`. |
| [`@unthrown/boxed`](./packages/boxed) | Boxed interop: `Result ↔ Result`, `AsyncResult ↔ Future<Result>`. |
| [`@unthrown/prisma`](./packages/prisma) | Prisma Client extension: `try*` query methods returning `AsyncResult`, per-operation errors. |
| [`@unthrown/drizzle`](./packages/drizzle) | Drizzle ORM Postgres database: every query an `AsyncResult`, tagged constraint violations, `Result` transactions. |
| [`@unthrown/orpc`](./packages/orpc) | oRPC (v2) bridge: `Result`-returning handlers, `AsyncResult` client, typed errors end-to-end. |
| [`@unthrown/standard-schema`](./packages/standard-schema) | `fromSchema` / `fromSchemaAsync`: any Standard Schema validator into a `Result`. |
| [`@unthrown/oxlint`](./packages/oxlint) | oxlint plugin: `no-ambiguous-error-type`, `no-catch-all-pattern`, `no-unhandled-result`, `no-unused-matcher`, `prefer-async-result`, `no-throw`, `prefer-ensure`. |

## Contributing

Expand All @@ -111,6 +112,10 @@ pnpm lint # oxlint
pnpm format # oxfmt
```

`@unthrown/drizzle`'s suite runs against a real PostgreSQL started by
[testcontainers](https://testcontainers.com), so **`pnpm test` needs a running
Docker daemon**. Every other package's tests are self-contained.

## License

[MIT](./LICENSE) © Benoit TRAVERS
2 changes: 2 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const GUIDE_SIDEBAR = [
{ text: "Handle results at the edge", link: "/how-to/handle-results-at-the-edge" },
{ text: "Validate with Standard Schema", link: "/how-to/validate-with-standard-schema" },
{ text: "Use with Prisma", link: "/how-to/use-with-prisma" },
{ text: "Use with Drizzle", link: "/how-to/use-with-drizzle" },
{ text: "Use with oRPC", link: "/how-to/use-with-orpc" },
{ text: "Test with Vitest", link: "/how-to/test-with-vitest" },
{ text: "Lint your codebase", link: "/how-to/lint-your-codebase" },
Expand Down Expand Up @@ -188,6 +189,7 @@ export default defineConfig({
{ text: "@unthrown/boxed", link: "/api/boxed/" },
{ text: "@unthrown/standard-schema", link: "/api/standard-schema/" },
{ text: "@unthrown/prisma", link: "/api/prisma/" },
{ text: "@unthrown/drizzle", link: "/api/drizzle/" },
{ text: "@unthrown/orpc", link: "/api/orpc/" },
],
},
Expand Down
Loading
Loading