feat(drizzle): add @unthrown/drizzle — Postgres/node-postgres integration returning AsyncResult - #195
Merged
Conversation
Add the package skeleton for the Drizzle ORM Postgres integration: two entrypoints (`.` and `./node-postgres`), build/test/typecheck/docs scripts, and catalog entries for drizzle-orm, pg, and the PGlite test harness deps. No integration logic yet — just a placeholder export and a smoke test to prove the package builds and runs. @types/pg and pg pin to the latest release outside the workspace's 7-day minimumReleaseAge window (8.20.0 and 8.22.0) rather than the brief's memory-written 8.15.6/8.16.3. drizzle-orm stays pinned to the brief's 1.0.0-rc.4 per policy (we subclass its internals).
Five TaggedError classes (UniqueConstraintViolation, ForeignKeyViolation, CheckViolation, ExclusionViolation, NotNullViolation) and qualifyPgError, which triages a Postgres driver failure into the modeled 23xxx integrity-constraint codes or the defect channel. Also collapses the package's typecheck script to a single tsc pass until Task 10 adds types.test-d.ts and restores the two-pass form.
PGlite (Postgres compiled to WASM) served over the real wire protocol by pglite-socket, so `pg.Pool` connects to genuine SQLSTATE-emitting Postgres in-process, no Docker required — same rationale as @unthrown/prisma's in-memory SQLite harness. maxConnections must be raised well above the pglite-socket default of 1: pg-pool destroys and reopens its connection on any query error, including the ordinary 23505s these tests provoke on purpose, and the replacement connection races the dying one's async teardown under the default cap (see task-1-report.md). Port 0 + getServerConn() picks up the OS-assigned port, avoiding a bind-then-reuse race. Includes a fixture-isolation test proving two concurrent startPg() instances never share tables, which task 9's connection-loss suite depends on.
Both of startPg()'s partial-startup failure paths (server.start() throwing, and an unparseable getServerConn() result) previously ran their cleanup steps sequentially and un-defended, unlike stop(): a failing server.stop() would skip db.close() (leaking the WASM instance) and would replace the original diagnostic error instead of surfacing alongside it. Factor the collect-and-continue discipline stop() already used into a shared collectErrors() helper, and route both startup failure paths (now unified in a failStartup() closure) and stop() through it. The original triggering error is always the one thrown; a cleanup failure is appended into an AggregateError rather than replacing it. Adds direct unit coverage of collectErrors()'s continue-past-failure behavior with deliberately failing steps, since forcing a genuine PGlite/pglite-socket failure to reach the startup paths isn't reachable without contorting the harness's design.
Eight subclasses of drizzle's base pg-core builders — select, insert, update, delete, count, raw, relational query and refresh-materialized-view — each adding the execution half (_prepare/prepare/execute) over an AsyncResult, plus UnthrownPgDatabase, the entry points that build them. Awaitability is a one-line `then` per builder rather than drizzle's `applyMixins(..., [QueryPromise])`: QueryPromise declares `execute(): Promise<T>`, which contradicts ours, and applyMixins is @internal and absent from drizzle's published .d.ts. The session's row-mapper parameter is widened to `never[]` (PgRowMapper) so drizzle's own mappers, which expect unknown[][], pass strictFunctionTypes.
`execute()` was `this._prepare().execute(pv)`, so only the driver call sat inside `fromPromise`'s thunk. `_prepare()` runs `getSQL()` -> `dialect.buildSelectQuery` -> `dialect.sqlToQuery`, which throws for a type-legal, reachable mistake (selecting a column from an unjoined table). Because `then` calls `execute()` synchronously, that throw escaped the thenable and REJECTED the awaiting promise — contradicting the package's stated contract and crashing a consumer folding with `match()` and no try/catch. Compilation now happens inside the boundary via one shared `runQuery` helper in awaitable.ts, covering all seven compiling builders; `raw.ts` already held a prepared query. `resultThen(this)` replaces the repeated `then` body. `db.execute()` stays eager and is documented as such: it is the builder factory, not the run, and deferring would cost PgRaw's synchronous accessors.
`UnthrownPgSession.execute` / `arrays` / `objects` called `dialect.sqlToQuery(...)` and `prepareQuery(...)` outside `fromPromise`, so a compilation throw escaped synchronously — past a caller who has no try/catch, because these methods promise a `Result`. The same bug was fixed across the seven builders; this is the last site, and Task 7 is the first caller of these methods. They now go through the `runQuery` helper, which runs compilation inside the same boundary that triages the driver's rejection. `PgQueryMode` is exported alongside, so a driver implementing the abstract session can name the parameter type it already has to accept.
`NodePgUnthrownSession` speaks to a real pg client; `NodePgUnthrownTransaction` is the handle its callback receives. Ok commits, Err and Defect both roll back. An Err still re-surfaces typed, so rolling back costs no information — and because rollback *is* returning an Err, there is deliberately no `tx.rollback()`. Since unthrown has no public way to mint a Defect, the sequence is written as a promise that may reject and qualified exactly once at a `fromPromise` boundary, then flattened with `flatMap`. The control statements go through `runUnqualified()`: awaiting an AsyncResult never throws, so a COMMIT run through the normal path would resolve to an Err nobody reads — reporting success having committed nothing. A COMMIT that raises 23505 (a DEFERRABLE constraint) is therefore still a modeled UniqueConstraintViolation, which is why `transaction`'s E carries PgQueryError whatever the callback's is. A rollback that fails takes over the outcome as a defect — the transaction's state is then unknown — but carries what it was undoing in an AggregateError, so it never destroys the failure it was rolling back. Nested transactions are savepoints under the same rule, and a pooled client is checked out for the transaction and released on every path, a rejecting control statement included.
Three findings from review of the node-postgres session. The pooling tests could not tell whether a transaction ran on the pooled connection or on the pool: the fake pool delegated `query` to its client, so both paths recorded into one array and every assertion passed either way. A pool hands out an arbitrary connection per statement, so fanning a transaction across it commits on a connection with no transaction open, leaves the work uncommitted and returns a stale open transaction to the pool — the worst bug available here, and invisible to the suite. The fake pool now records separately, the pooling tests assert the pool itself ran nothing, and a builder query inside a pooled transaction covers the realistic shape. Savepoint names now come from a counter shared by every handle descended from one transaction, not from nesting depth. Depth alone gives two sibling nested transactions the same `sp1`; started concurrently — which `allAsync` makes easy to write — the first `rollback to savepoint sp1` unwinds the other's work. `#runSavepoint` built the nested handle with the four-argument form, so `parseRqbJson` silently reverted to false one level down. It is threaded now.
Replace drizzle-orm/node-postgres' own `drizzle()` rather than wrapping it:
every method on the returned database already speaks `AsyncResult`, so
migrating a call site is an import change. Keeps drizzle's connection-string,
`{ client }` and `{ connection }` forms, plus the positional-client form the
task brief specifies, discriminated by drizzle's own `isConfig` (a `pg.Client`
carries a `connection` property of its own, so a key-presence test would
misread one).
Wire `transaction` onto `NodePgUnthrownDatabase` as a one-line delegate to
`NodePgUnthrownSession.transaction` — the same shape drizzle's own async
database uses — which makes Task 7's `db.transaction(...)` TSDoc examples true.
The base `UnthrownPgDatabase` deliberately stays without it: its session's
transaction handle is unresolved, because the base facade is built underneath
the transaction class that extends it.
Decide the public surface: the root entry exports the dialect-agnostic pg-core
tree (database, session, prepared query, the eight builders and their result
types) so consumers can write type annotations; `./node-postgres` exports the
factory, database, config and session/transaction classes. Also exports
`PgQueryMode`, `PgRowMapper` and the Insert/Update/DeleteResult types, which
were module-local but appear in public signatures.
…e positional drizzle() form
Reads had `E = PgQueryError`, forcing every caller to enumerate five
integrity-constraint violations a SELECT can never raise. Narrow the four read
builders — select, $count, db.query.*, refresh materialized view — to
`AsyncResult<T, never>`, and route them through a new `runSafeQuery`
(`fromSafePromise`, the named "everything here is a defect" boundary) so the
runtime cannot contradict the type.
The runtime half is the point. Declaring `never` while still qualifying would
let a 23xxx raised on a read path — a SELECT calling a volatile function that
writes — surface as an Err the type says is impossible; a type-exhaustive
mapErrCases would then throw NonExhaustiveError and the modelled error would
silently become a Defect. That is the trap @unthrown/prisma shipped by omitting
RecordNotFound from create/upsert's E. Covered by tests that provoke a real
23505 from a read and assert the Defect channel, verified to fail when only the
runtime half is reverted.
Writes are untouched: insert/update/delete, db.execute and transaction keep the
full PgQueryError union. `ResultThen` gains an `E` parameter defaulting to
PgQueryError, so the write builders are unchanged.
Also drop the positional `drizzle(pool)` overload. Upstream
node-postgres/driver.d.ts has only three forms, and a fourth spelling of
`{ client: pool }` breaks the promise that migrating a call site is purely an
import change. `isConfig` goes with it — the three remaining forms discriminate
on `typeof === "string"` alone.
`prepare(name).execute()` is a third way to run a read, next to `execute()` and `await`, and it went through `UnthrownPgPreparedQuery.execute` — which qualifies. So a prepared read produced `Err(UniqueConstraintViolation)` for the exact case the builder's `E = never` says is impossible, making "a read has no modeled failure" false wherever `prepare()` is used. Add `UnthrownPgSafePreparedQuery`, whose `execute` routes through `fromSafePromise` and returns `AsyncResult<T, never>`, and hand it back from the three read builders that expose `prepare()` — select, db.query.*, and refresh materialized view. (`$count` has none: drizzle's `PgCountBuilder` exposes no `prepare`.) Writes are untouched and still return the qualifying class. A subclass rather than a type parameter on `UnthrownPgPreparedQuery`: a parameterised `E` must pick its boundary from an injected function, and the injected default is not assignable to an unresolved `E`, so that shape needs a cast exactly where type and runtime must not drift. Overriding `execute` needs none — `AsyncResult` is covariant in `E` — and the narrow type is reachable only through the class whose `execute` is the safe one. Also correct the refresh-materialized-view rationale, which was factually wrong: a refresh CAN raise 23505 against a unique index, and CONCURRENTLY requires one. It stays a defect under the "would you branch on it?" rule — a view whose query yields duplicates is a bug in the view definition. Drop two bare `as never` casts in the spec by typing the helper at `Result<unknown, never>`, which also narrows `isDefect` so `cause` needs none. Move the relational-query test ahead of the error-provoking cases: pg-pool destroys its connection on any query error, and the replacement racing the dying one's teardown intermittently desynced that read's response framing.
Twenty-six cases against a real PostgreSQL (PGlite over the wire protocol):
the five modeled 23xxx codes with their constraint/table/column metadata, the
defect routing for 42601/42P01/22P02 and a genuinely lost connection, the
reads-are-E-never ruling proved by a refresh that raises a real 23505, and the
transaction semantics on both a pool and a bare pg.Client, each rollback
checked against the database rather than against the returned Result.
Every fixture keeps exactly ONE live connection. PGlite is a single backend and
pglite-socket multiplexes every TCP connection onto it, so a second connection
is contention on the one session rather than an independent one; a dedicated
probe connection reproduced a response-framing desync ("Received unexpected
parseComplete message from backend"). Driving the error-provoking blocks
through a bare pg.Client also removes the pool churn behind the Task 8 flake:
Pool.prototype.query releases a client WITH the error, which destroys and
reopens the connection, while a standalone client and a checked-out PoolClient
do not.
The six absence checks went through $count, whose countOf maps a missing cell to 0 (keeping drizzle's own coercion). A read that came back EMPTY — the response-framing desync this suite exists to be honest about — was therefore indistinguishable from a genuine zero, and every toBe(0) passed vacuously. The one failure mode the suite must be able to see was the one it could not. Each is now a SET assertion over candidate ids that names at least one row which must still be there, so a vacuous [] fails instead of passing. Demonstrated with the driver stubbed to return an empty rowset: the old toBe(0) passes, the new toEqual([1]) throws. Also releases the lost-connection fixture on its failure path. solo.stop() is a step of that test, not only its cleanup, so it takes an idempotence guard rather than a second stop() in the finally — stop() closes the WASM instance and a second db.close() would surface as an AggregateError out of the cleanup.
…ners PGlite (served over the wire protocol by `pglite-socket`) kept the suite Docker-free, but electric-sql/pglite#958 makes it unusable: after an errored extended-query batch it answers the client's `Sync` with a SECOND `ReadyForQuery`, which real PostgreSQL never sends. Measured on 2000/2000 errored batches; when the two land in separate TCP segments `pg` has already dispatched the next query, so the stray `Z` completes it early with `rows: []` and everything after it desyncs. The integration suite failed 7 runs in 8. Neither `@electric-sql/pglite` 0.5.4 nor `pglite-socket` 0.2.7 fixes it, and the issue is still open. `startPg()` keeps its contract — `{ pool, stop }` — so no spec changed shape. What changed is underneath it: a vitest `globalSetup` starts ONE `postgres:18.4-alpine` container for the whole run (pinned to an exact patch, so a server upgrade can never move an error message or a SQLSTATE under a run that changed no code), hands its address to every worker through `provide`/`inject`, and stops it at the end; each `startPg()` then costs a `CREATE DATABASE` rather than a container boot. Isolation stays per database, so the concurrent-fixtures test is unchanged. `stop()` keeps the `collectErrors`/`failStartup` discipline: pool first, then `DROP DATABASE IF EXISTS ... WITH (FORCE)` — FORCE because a spec may deliberately still hold a connection — with every step attempted independently. A pool probe gives the partial-startup path a real trigger and proves the fresh database is reachable before a spec's first query. Verified: zero `unthrown_*` databases survive a run, and no container outlives it. 12/12 consecutive clean runs (170 tests), 5.3s -> ~2.0s per run.
The 23P01 case had been standing in a scalar `EXCLUDE (room WITH =)`,
because PGlite ships no `btree_gist` (`CREATE EXTENSION btree_gist`
fails 0A000). A real PostgreSQL has it, so the constraint is now the
canonical form the feature exists for — `EXCLUDE USING gist (room WITH
=, during WITH &&)`, no two bookings of one room overlapping in time —
and the DDL's limitation note is gone.
`during` is a `tstzrange`, declared through drizzle's `customType` since
it has no built-in column for one, and the two inserts now overlap in
time rather than merely repeating a room. PostgreSQL derives the
constraint name from every column in it, so the assertion follows to
`bookings_room_during_excl` — still the server's own name, not one the
schema chose. Verified to raise a genuine 23P01.
Also updates the comments that were PGlite rationale and would now read
as false: the single-multiplexed-backend note explaining why most of the
file drives a bare `pg.Client` (it stays, for what it covers on its own
account), and the desync notes on `rowsUnder`/`survivorsOf` — the paired
positive controls themselves are untouched, and so is every assertion
bar the constraint name above. The lost-connection case gains a
`pool.on("error")` listener: `stop()` now drops the database `WITH
(FORCE)`, and an unlistened `error` event on a `pg.Pool` throws.
Review finding 1. `runOnAdminDatabase` collapsed `client.query(statement)`
and `client.end()` into one error list and rethrew whichever came out, so
a `CREATE DATABASE` that SUCCEEDED on a connection that then failed to
close rejected `startPg` before the pool existed: the caller never got a
`stop()`, and the database was orphaned until the container died. The
comment above the call claimed the opposite postcondition — "nothing to
release if this fails: no database was created".
The two steps are now collected separately, and the helper throws if and
only if the STATEMENT failed. A close failure on the success path is
swallowed deliberately: `pg` destroys the socket on its way out of a
failing `end()`, so there is no resource left for a caller to act on, and
reporting it would break the biconditional that is the only thing keeping
a fresh database attached to a handle that can drop it. The close is
still attempted either way — a failed statement cannot skip it — and a
genuine pair still surfaces as an `AggregateError`. Both the doc comment
and the call-site comment now state the contract they rely on.
Covered by a new case that forces the failure (`pg.Client#end()` does not
fail on its own): the spy closes the connection for real, then throws, so
the test leaks nothing and only the REPORTING is broken. Verified to fail
against the previous implementation —
× still hands back a usable fixture when the admin connection will
not close
Error: admin connection refused to close
Review findings 2 and 3. Two comments still explained the PGlite failure as pool churn against the one multiplexed backend. That was an early hypothesis the investigation inverted — the churn was a mitigation, not the fault; the real cause is the duplicate `ReadyForQuery` after an errored extended-query batch (pglite#958), which test-harness.ts already records. A reader was getting the discredited account from two spec files and the correct one from the harness. Both now point at #958 and say the pool-churn theory was wrong. The lost-connection case also no longer described what it provokes. The old harness took the server away; `stop()` now drops the database `WITH (FORCE)`, so the query that follows hits either a terminated backend or, far more often, a reconnect that finds no such database. It is renamed to "routes a database dropped underneath a live pool to the defect channel", and `expectDefectCause`'s discarded return is now asserted rather than dropped — unlike its three sibling defect cases, which each pin a SQLSTATE. A probe loop measured 3D000 (invalid_catalog_name) on 4/4 iterations, so that is the outcome in practice; the socket-death arm is kept in the same single assertion because it is a genuine race, and pinning only 3D000 would make an unobserved race exactly the kind of flake this harness was rewritten to remove. Verified live by mutating the pattern, which reports the real cause rather than passing vacuously.
…nels Pins the package's central typing promise: reads infer `E = never` (select, $count, db.query.*, refresh materialized view — through all three routes, including `prepare(name).execute()`), writes carry the full `PgQueryError` union (insert/update/delete, `db.execute(sql`…`)`, transaction), and the matcher's exhaustiveness and defect subtraction hold on top of them. Over-narrowing an error channel is the trap @unthrown/prisma already paid for: a write's `E` omitted an error the runtime still produced, so a type-exhaustive `mapErrCases` threw NonExhaustiveError and the modeled error silently became a defect. These assertions guard both directions. Restores the two-pass `typecheck` script — tsconfig.test-d.json had no inputs before this file existed, which is why Task 3 collapsed it to one pass.
…assertion `ErrOf<R>` answers `never` for anything that is not a `Result` at all, so an err-only `Equal<ErrChannel<…>, never>` pinned "empty channel OR not wrapped". Eight read assertions had no `OkChannel` pin or `.get()` on the same route and so passed with the body replaced by plain rows — including both `findMany` routes and the prepared refresh, and the prepared paths are exactly where a builder most plausibly stops routing through `UnthrownPgSafePreparedQuery`. Pairs each with an `OkChannel` pin, which forces the value to still BE a `Result` carrying the right rows. Also pins the two-argument factory form (the only place relations flow through the string overload) and drops the export block, matching packages/core/src/types.test-d.ts.
…AUDE.md and the agent skill Ship the documentation for @unthrown/drizzle and make its API reference part of the site: - docs/how-to/use-with-drizzle.md, mirroring the Prisma page: the factory's call forms, reads inferring E = never (and why that is enforced at runtime rather than merely declared), the five modelled SQLSTATEs, the defect channel with a recoverDefect retry wrapper for 40001/40P01, transactions and the "rollback is returning an Err" rule, and the db.$client escape hatch. - Wire the package into the VitePress site: guide sidebar, API sidebar, copy-docs, and the docs workspace dependency. - CLAUDE.md: a packages/drizzle bullet in the monorepo layout, plus the Docker requirement its testcontainers harness introduces. - The agent skill gains a Drizzle section (it is a hand-maintained second copy and drifts). Also correct three things the prose got wrong. The README hung .mapErrCases and .flatMap directly off a query builder, which is a thenable with no such methods, and pointed a ^? annotation at the wrong expression; db.ts's @example blocks were inherited verbatim from drizzle and showed awaited builders as if they yielded rows. src/docs-examples.test-d.ts now compiles every sample the package ships in prose, so they cannot rot silently again. build:docs is warning-free: the 39 TypeDoc warnings were @internal helpers linked with {@link} (unlinked), a @param bound to the wrong overload, and the module-local ConstraintFields (intentionallyNotExported).
…the docs mirror CLAUDE.md described the savepoint naming as "depth-based naming, drizzle's scheme, collides under concurrently-started nested transactions" — a parenthetical that had lost its contrast marker, so it read as three appositives describing our own counter. The source says the opposite: the counter is shared across every handle descended from one transaction, and claimed before anything is issued, precisely BECAUSE depth-based naming would give two concurrently-started nested transactions the same `sp1`. As written the spec asserted the hazard the implementation exists to remove. The docs mirror opened "Every code sample this package ships in prose, compiled", which overclaimed twice: it is a hand-maintained mirror rather than an extraction, and it checks shapes against a locally declared schema. It also skipped the one sample most able to rot on its own — the `db.$client` hand-off to a stock drizzle database, whose shape depends on a foreign factory's signature across the peer range. That sample is now covered (verified non-vacuous: breaking `client:` fails the typecheck), and the header states what is not guaranteed, with the three deliberate divergences numbered and marked at the sites where they occur.
Seven items from the final review before merge. No behaviour outside them.
Important 1 — unify the public class naming. The four `UnthrownPg*` exports
become `PgUnthrownDatabase`, `PgUnthrownSession`, `PgUnthrownPreparedQuery` and
`PgUnthrownSafePreparedQuery`, matching both the twelve `PgUnthrown*` siblings
already in `index.ts` and drizzle's own uniform `PgAsync*` tree. `NodePgUnthrown*`
is left alone — it already mirrors drizzle's `NodePgSession`/`NodePgDatabase`.
Settled now because it is public API and unpublished. Noted in the existing
changeset rather than a second one.
Important 2 — restore the query text on failures. `runUnqualified` wraps a driver
rejection in drizzle's own `DrizzleQueryError` before triage, exactly as
`PgAsyncPreparedQuery.execute` does, so a defect names the failing statement and
its params; node-postgres' `DatabaseError` carries `code`/`constraint`/`table`/
`column`/`detail` but not the SQL. Triage is unaffected: `qualifyPgError` already
read the SQLSTATE through one `cause` level, and the earlier "needs the raw
SQLSTATE" rationale was simply wrong.
Important 3 — guard `runScope` against a non-`Result` callback return.
`isOk`/`isErr` read `.tag`, which throws on `null`/`undefined` — the shape a JS
caller produces by forgetting the `return` in `async (tx) => { await ... }`. That
TypeError escaped before any ROLLBACK, so the pooled client went back with BEGIN
still open and the next borrower ran inside a stale transaction. An `isResult`
check now routes anything out of contract to the undo path, and core's own
non-`Result` guard mints the defect.
Minors: drop the scaffold `smoke.spec.ts`; omit an empty transaction-config
clause (`begin `/`set transaction ` were syntax errors, and an empty
`setTransaction` now issues nothing); order `undoScope`'s `AggregateError` as
`[thrown, original]`, core's failure-observer convention; reword the pglite-era
harness test name.
Every fix is pinned by a test proven to fail without it, including a real-Postgres
case asserting the pooled client comes back clean. Existing assertions whose shape
the wrapper changed were strengthened to identity checks, never weakened.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new satellite package, @unthrown/drizzle, providing a Drizzle ORM Postgres + node-postgres integration where queries resolve to AsyncResult/Result values (never rejecting promises), with integrity-constraint SQLSTATEs modeled as tagged domain errors and infrastructure failures routed to the defect channel. The PR also wires the new package into the monorepo’s docs and references.
Changes:
- Introduces
packages/drizzleimplementing an unthrown-backed Drizzle pg-core builder/session tree plus a node-postgres driver and transaction semantics. - Adds an extensive test suite (runtime + type-level + docs-example compilation) and Vitest + TypeDoc configs for the new package.
- Updates documentation and repo references (README, docs site, skill docs, CLAUDE.md) to include the new integration and its Docker-backed test requirements.
Reviewed changes
Copilot reviewed 45 out of 46 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| skills/unthrown/SKILL.md | Updates skill reference list to include the new Drizzle integration. |
| skills/unthrown/references/ecosystem.md | Documents @unthrown/drizzle usage and behavior in the ecosystem reference. |
| README.md | Adds @unthrown/drizzle to the package list and documents Docker requirement for tests. |
| pnpm-workspace.yaml | Adds catalog pins for Drizzle/Testcontainers-related deps and allowBuilds overrides. |
| packages/drizzle/vitest.config.ts | Adds Vitest config with globalSetup container boot and always-on coverage thresholds. |
| packages/drizzle/typedoc.json | Adds TypeDoc config for @unthrown/drizzle entrypoints. |
| packages/drizzle/tsconfig.test-d.json | Adds a second tsc pass config for type-level test files. |
| packages/drizzle/tsconfig.json | Adds package TS config (notably declarationMap: false and node types). |
| packages/drizzle/src/types.test-d.ts | Adds type-level assertions for per-operation error channels and API surface invariants. |
| packages/drizzle/src/test-harness.ts | Adds PostgreSQL fixture harness that provisions per-test databases and robust cleanup. |
| packages/drizzle/src/test-container.ts | Adds Vitest globalSetup using testcontainers to boot a shared PostgreSQL container. |
| packages/drizzle/src/pg-core/update.ts | Implements unthrown update builder (AsyncResult execution + thenable awaiting). |
| packages/drizzle/src/pg-core/session.ts | Implements unthrown session + prepared query execution and safe/read routing. |
| packages/drizzle/src/pg-core/session.spec.ts | Tests prepared query qualification/defect behavior and session raw-SQL entrypoints. |
| packages/drizzle/src/pg-core/select.ts | Implements unthrown select builder with E = never via safe execution. |
| packages/drizzle/src/pg-core/refresh-materialized-view.ts | Implements refresh-matview builder treated as read (E = never) by design. |
| packages/drizzle/src/pg-core/raw.ts | Implements raw db.execute(sql\...`)wrapper returningAsyncResult`. |
| packages/drizzle/src/pg-core/query.ts | Implements relational (db.query.*) builder with safe/read routing (E = never). |
| packages/drizzle/src/pg-core/insert.ts | Implements unthrown insert builder with modeled PgQueryError channel. |
| packages/drizzle/src/pg-core/delete.ts | Implements unthrown delete builder with modeled PgQueryError channel. |
| packages/drizzle/src/pg-core/count.ts | Implements $count builder treated as read (E = never) with safe execution. |
| packages/drizzle/src/pg-core/awaitable.ts | Adds then implementation + helpers ensuring compilation happens inside boundaries. |
| packages/drizzle/src/node-postgres/index.ts | Exposes the node-postgres driver/session entrypoint for consumers. |
| packages/drizzle/src/node-postgres/driver.ts | Implements the drizzle() factory + database facade and transaction delegation. |
| packages/drizzle/src/node-postgres/driver.spec.ts | Integration tests against real PostgreSQL for reads/writes/errors/tx/logging. |
| packages/drizzle/src/index.ts | Defines the public export surface for the package (errors, builders, session types). |
| packages/drizzle/src/harness.spec.ts | Tests the harness behavior (isolation, cleanup, startup edge cases). |
| packages/drizzle/src/errors.ts | Defines tagged constraint-violation error types and qualifyPgError triage. |
| packages/drizzle/src/errors.spec.ts | Unit tests for qualifyPgError mapping and defect routing behavior. |
| packages/drizzle/src/docs-examples.test-d.ts | Adds compiled mirrors of prose examples to prevent doc/sample rot. |
| packages/drizzle/README.md | Adds package-level README with usage, error model, and Docker test note. |
| packages/drizzle/package.json | Adds new package manifest (exports, peers, scripts, deps). |
| packages/drizzle/LICENSE | Adds per-package MIT license file. |
| docs/scripts/copy-docs.ts | Ensures docs build copies/generated docs include @unthrown/drizzle. |
| docs/package.json | Adds @unthrown/drizzle as a docs-site dependency for building reference pages. |
| docs/how-to/use-with-drizzle.md | Adds a full how-to guide for using unthrown with Drizzle + node-postgres. |
| docs/api/index.md | Adds @unthrown/drizzle to the API index listing and updates oxlint rule listing text. |
| docs/.vitepress/config.ts | Adds Drizzle to sidebar and API nav menus. |
| CLAUDE.md | Documents the new package’s design/semantics and the Docker-backed test suite exception. |
| .changeset/great-hoops-hammer.md | Adds a changeset announcing the new package and its public API/behavior. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…review feedback brace-expansion 5.0.8 and fast-uri 3.1.4 were both pinned as security fixes; both advisories have since been widened past those versions, so the pins were stale. Lift them to 5.0.9 and 3.1.5, and add a 2.x brace-expansion pin for the line @testcontainers/postgresql pulls in through archiver. All three patched releases are younger than the 7-day minimumReleaseAge cutoff, so re-add the temporary excludes PR #190 removed once the previous round had matured, each naming its removal condition. Also from PR review: start @unthrown/drizzle at 0.0.0 so the minor changeset publishes 0.1.0 rather than skipping it, and fix a skill snippet that described an awaited builder as an AsyncResult when it is a Result.
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.
What & why
Adds
@unthrown/drizzle— a Drizzle ORM integration for the Postgres dialect and thenode-postgresdriver, where every query returns anAsyncResult<T, E>instead of a rejecting promise.Drizzle
1.0.0-rc.4ships a first-party Effect integration but nothing equivalent for us. It is cheap for drizzle because it lives in-repo: drizzle maintains parallel per-dialect trees (pg-corebase builders,pg-core/async,pg-core/effect) over a deliberately container-agnostic session —PgSession.execute()andPgBasePreparedQuery.execute()both returnunknown. This package is a fourth sibling tree over those same bases.The error model — the actual value-add
Drizzle's Effect integration models a single blanket
EffectDrizzleQueryError. This applies the repo's "would you branch on it?" rule instead (Thesis #1,@unthrown/prisma's precedent):Eselect,$count,db.query.*,refreshMaterializedViewneverinsert,update,delete,db.execute(sql…)PgQueryErrortransactionE∪PgQueryErrorPgQueryErroris the five integrity-constraint SQLSTATEs —23505unique,23503FK,23502not-null (carriescolumn),23514check,23P01exclusion. Everything else is aDefect: deadlock, serialization failure, timeouts, connection loss, syntax errors, non-Postgres causes. Retry belongs in onerecoverDefectwrapper, not an arm at every write call site.E = neveron reads is enforced at runtime, not merely typed. The read builders route throughfromSafePromise, so a23xxxreaching a read path becomes aDefectrather than anErrthe type says is impossible — including throughprepare(name).execute(). Declaring it on the type alone would rebuild the trap that bit@unthrown/prisma, where the runtime produced an error its type excluded and a type-exhaustivemapErrCasessilently turned the modelled error into aDefect.Behaviour worth reviewing
dialect.sqlToQuerythrows synchronously for a type-legal user mistake (selecting a column from a table not in theFROM). Compiling outside the boundary madeawait db.select(…)reject, breaking thematch({ ok, errCases, defect })-with-no-try/catchcontract this library exists to provide.Okcommits;ErrandDefectboth roll back, with the error re-surfacing typed. There is deliberately notx.rollback()— rollback is returning anErr. Control statements use a raw rejecting path so a failedCOMMITcannot resolve to an unreadErr; aDEFERRABLEconstraint meansCOMMITitself can raise23505, which is whytransaction'sEalways includesPgQueryError. Savepoints for nesting, counter shared per connection and claimed before any statement issues.drizzle(pool)— only drizzle's own forms, so migrating a call site is an import change. Escape hatch: a stockdrizzle-orm/node-postgresdb over the samePool;db.$clientis kept.Testing
Runs against a real PostgreSQL via testcontainers (
postgres:18.4-alpine), one container per run with a fresh database per fixture. 186 tests, including one per SQLSTATE provoked by a real constraint, defect routing, and transaction semantics down to theDEFERRABLE-at-COMMITcase.This started as PGlite-over-the-wire-protocol to keep the suite Docker-free, and was abandoned after a confirmed upstream bug: PGlite sends a duplicate
ReadyForQueryafter an extended-query batch whoseExecuteerrors, sopgcompletes the next query early withrows: []and desyncs (electric-sql/pglite#958, open). Measured on 2000/2000 errored batches; the suite failed 7 runs in 8.This means
pnpm testnow requires Docker, a departure from the repo's self-contained-suite convention, documented in the package README, the docs page andCLAUDE.md. CI needs no changes — every job in the sharedci-reusable.ymlisruns-on: ubuntu-latestwith no jobcontainer:.Known cost
The package subclasses drizzle APIs marked
@internal. The exposure is concentrated and breaks at compile time, not runtime: everything it touches is typed in drizzle's published.d.ts, andnoImplicitOverrideis on. The peer is^1.0.0-rcwith the catalog pinned to exactly1.0.0-rc.4, so bumps are deliberate. The package sits outside the fixed version group, like@unthrown/prismaand@unthrown/orpc.Follow-ups (not blocking)
CLAUDE.md:878claimsbeginandset transactionare both syntax errors. Only the second is — Postgres skips trailing whitespace. As a resultintegration.spec.ts:646-655does not bind despite its comment saying so.causeis now theDrizzleQueryErrorwrapper rather than the rawDatabaseError(the price of restoring query text on failures). Documented in the docs' defect section, but the write-error table and README still listcauseunqualified.Checklist
pnpm format --check && pnpm lint && pnpm typecheck && pnpm knip && pnpm test && pnpm buildpnpm changeset) for any user-facing changeCLAUDE.mdif the public surface or a design rule changed