@btravstack/prisma
The Prisma starter for
@btravstack/core:DATABASE_URLbound throughConfig, per-query observation as middleware, and a client whose pool is the application scope's. Prisma 8.
📖 Documentation · API Reference
pnpm add @btravstack/prisma @btravstack/core @btravstack/config @btravstack/di unthrown \
@prisma/[email protected] [email protected]
Install the required peers, so the application holds a single copy of each;
temporal-polyfill is the Postgres package's own peer from rc.14. The
@prisma/orm-postgres peer is ^8.0.0-rc.11, and both ends are tested: an
application already on rc.11 keeps it.
Upgrading with released migrations: upgrade @btravstack/prisma and its
@btravstack/* peers, and leave Prisma where it is. No contract, migration or
database marker changes. Upgrading Prisma itself from rc.11 rewrites every
released migration and re-signs every database, which is Prisma's path rather
than this package's; see
Supported Prisma trains. The prisma CLI is yours, as a dev dependency: the client is
built from your emitted contract, so there is none for this package to ship.
Node >=22.12.
Production packaging: the pinned Postgres facade requires
@prisma/orm-toolchain, including its CLI-engine peer and build-tool
closure. A /runtime import and pnpm deploy --prod do not remove those
transitive dependencies. Keep contract emission in the build stage and
migrations in a deployment Job, with strict peers enabled; a toolchain-free
runtime install remains blocked upstream.
That section documents the manifest split and the tracked dependency.
This is Prisma 8, which is a different package family rather than a version
bump — @prisma/orm-postgres replaces @prisma/client and @prisma/adapter-pg
both, and there is no @prisma/instrumentation. For Prisma 7 use
@btravstack/[email protected].
A worked example
import { prismaDatabase } from "@btravstack/prisma";
import { contractFromJson } from "@prisma/orm-postgres/static";
const contract = contractFromJson<Contract>(contractJson);
const database = prismaDatabase("OrderDatabase")({
client: ({ url, middleware }) => postgres({ contract, url, middleware }),
});
That is the whole surface. database is a module carrying the port a
composition root reads; the provider binding DATABASE_URL, the resourceful
client provider and the health check are inside it:
export const PersistenceModule = Module("Persistence")({
imports: [database],
exports: [database.port],
needs: [Env],
});
The client type is yours, and that is deliberate. A Prisma 8 client is
typed by your emitted Contract and built from your contract.json, so
there is none for this package to ship. From Prisma rc.19, turn that JSON into
a typed value once with contractFromJson<Contract>(contractJson) and pass
contract to postgres({ contract, ... }). The client arrow is where your
contract and this starter meet, and the port is typed by exactly what it
returns. Spread the middleware it hands you into the client: that is where
the per-query observation below comes from.
What it owns
DATABASE_URLthroughConfig. A missing or blank value is a modeledConfigInvalidnaming the variable, not a throw — so a misconfigured deployment exits78with the reason on stderr instead of crashing on the first query.- The pool's lifetime. The provider is resourceful, so
releasecloses it on every exit path, including a boot that fails after it ran. - A database that answers before any work runs. Opening the scope runs the
health check's probe, so a fresh pool is verified before its first burst of
transactions, and a database that does not answer fails the boot with
DatabaseUnreachable— the database's name and the driver's cause, never the URL. - A role row security applies to. Where any table has row-level security
enabled, a role that is a superuser, has
BYPASSRLS, or owns a row-secured table that is notFORCEd fails the boot withRowSecurityBypassed, naming the role and the tables it would bypass — every policy would admit every row for it. - A health check, named after the starter —
SELECT 1through the raw lane, folded into the kernel'sGET /healthzwith nothing wired./readyzdoes not read it: failing readiness on a dependency every replica shares removes them all at once. - Every query handed to
Observers, as one statement middleware, so composingobservability()writes the failures as lines andotel()opens the spans and mints the instruments — with no flag here and no port list to satisfy when you compose neither. A middleware sees the ORM lane, the SQL builder and the raw lane alike, and carries the runtime's ownlatencyMs.
Results, on the @btravstack/prisma/result subpath
Prisma 8 throws a structured error carrying PostgreSQL's own SQLSTATE.
tryQuery turns that into a Result with three modeled arms —
UniqueConstraintViolation (23505, with the constraint the database
named), ForeignKeyViolation (23503) and NotAuthorized (42501,
including a write a row-security policy refused). Anything else is a defect,
unless you list it in raises — a SQLSTATE your own trigger raises on purpose
then arrives as a RaisedException carrying that code.
const saved = tryQuery(() => db.orm.orders.Order.create({ orderId })).mapErrCases(
(matcher, defect) =>
matcher
.with(P.tag("UniqueConstraintViolation"), () => duplicate(orderId))
.with(P.tag("ForeignKeyViolation"), P.tag("NotAuthorized"), (cause) => defect(cause)),
);
It takes a thunk rather than a promise: an AsyncResult is eager, so a
promise built at the call site has already started before the Result exists.
Row-level security, on the @btravstack/prisma/rls subpath
The policy is declared in your contract — @@rls on the model and a
policy_all block beside it — and the planner emits ENABLE ROW LEVEL SECURITY and CREATE POLICY like any other migration operation. The pin is
a transaction:
const orders = await tenantPinned(db, tenant, (tx) => tx.orm.orders.Order.all());
For a consistent snapshot, pass { isolationLevel: "repeatable read", readOnly: true }
as the fourth argument. The starter establishes these characteristics before
the tenant pin. Use its boot-probed client and the callback's tx for every
related read; omitted characteristics preserve the connection defaults. See
consistent snapshots.
set_config(…, true) is transaction-local, which is why this opens one
rather than handing back a pinned client: a session-scoped pin works only while
the pool happens to return the same connection, and fails silently the first
time it does not. The policy must read the same setting — app.tenant_id
is only the default — because a tenantPinned(db, tenant, work, { setting })
and a policy naming a different one deny every row and every write, which looks
exactly like row security working.
Two halves stay the deployment's, and both fail quietly when forgotten: Prisma 8
authors policies but no GRANTs, and it emits ENABLE ROW LEVEL SECURITY with
no way to say FORCE — so the table's owner, which is the role that ran the
migrations, bypasses every policy. Connect as a role that is neither the owner
nor a superuser — the boot refuses either with RowSecurityBypassed. The
reference page
carries the DDL and the smaller lines that fail the same way.
Tenant discovery for scheduled work
tenantListing on /rls declares a tenant-ID-only query (100 IDs per page,
resuming after the last ID) and a
checked Prisma migration installing its function, owner policy and execution
grant. It works under forced row-level security while ordinary application
reads remain tenant-scoped. Shared data does not require a privileged
application connection. See
tenant listing.
What it does not
Running migrations. A deployment runs prisma db migrate against the same database
before the process starts — and, where row security is on, as its owner
rather than the role DATABASE_URL carries. An application that migrates itself
at boot races every other replica.
Engine-level tracing. There is no engine: Prisma 8 is a TypeScript runtime and ships no instrumentation package, so the statement middleware is the whole seam and it already carries the runtime's own latency.
Transactions. Commit boundaries belong to the adapter, spelled at the call —
db.transaction(fn) is the primitive. The rls subpath opens one transaction
to pin a setting on and the work inside it is yours. There is no unit-scoped
transaction and there will not be one; see
the kernel maps nothing.
A repository base class. Ports are your application's vocabulary, not this package's.
Options
| Option | Where | What it is |
|---|---|---|
name | prismaDatabase(name) | the port's id and the health check's name — required |
client | prismaDatabase(name)({ client }) | builds your client from the URL and middleware this package bound — required |
DATABASE_URL | environment, read by prismaDatabase(name) | the connection string — required, validated at graph build; blank is an error, exit 78 |
setting | tenantPinned(db, tenant, work, { setting }) | the PostgreSQL run-time setting the policy reads — default app.tenant_id |
isolationLevel, readOnly | tenantPinned(db, tenant, work, options) | transaction characteristics established before the pin — connection defaults when omitted |
raises | tryQuery(run, { raises }), tenantPinned(…, { raises }) | SQLSTATEs modeled as RaisedException rather than defects — default none |
schema, table, tenantColumn | tenantListing(options) | the physical table and tenant column for a narrow listing |
name, owner, role | tenantListing(options) | the listing function, its trusted owner and application caller |
There is no instrumented flag: observation is a set port every call is
handed to, so a graph composing no observability pays one inert call and no
port list. The full table — defaults, semantics and the reasoning — lives on
the reference page,
which is this list's one detailed home.
License
MIT