@aventara/prisma7-adapter
v1.0.0-rc.12
Published
The Prisma 7 adapter for Aventara.
Downloads
1,923
Readme
@aventara/prisma7-adapter
The Prisma 7 Adapter for Aventara. It discovers what a Prisma schema and its generated client can do,
compiles that into an Aventara AvAdapterModel, and executes validated Aventara operations against a
Prisma client the application owns.
This README is for the person setting the adapter up in an application. It covers what the adapter does, what it does not offer, and the two operations whose guarantee is weaker than their names suggest. Everything you need is in the body of this file, not in footnotes.
- Status: release candidate. Two engines are proven, PostgreSQL and SQLite. Every other Prisma provider is unmeasured, and this file says so where it matters.
- Peer dependencies:
@prisma/client^7.10.1(required);prisma,@prisma/adapter-better-sqlite3and@prisma/adapter-pg^7.10.1andtypescript>=5.7.0 <7(optional). The error map targets 7.10.0, and the adapter is bound to Prisma 7's error surface; see Prisma 7 only.
The application owns the Prisma client, and a driver adapter is required
createPrismaAdapter takes a client that is already constructed. It never constructs one, never
reads a connection string, and never reads an environment variable. The application owns the client's
lifetime, its pool, its logging, its extensions and its $disconnect.
Prisma 7 reaches a database through a driver adapter, so the client you hand over must have been built with one. There is no fallback path, in Prisma or here.
import { PrismaPg } from "@prisma/adapter-pg";
import { createFramework } from "@aventara/core";
import { createPrismaAdapter } from "@aventara/prisma7-adapter";
import { PrismaClient } from "./generated/client.js"; // your generated client
import { discovery } from "./generated/aventara.artifact.js"; // the generate step's output
const client = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const adapter = await createPrismaAdapter({
client,
discovery,
provider: "postgresql", // declared, never sniffed
driver: "@prisma/adapter-pg", // declared, never sniffed
// Adapter-owned, and not part of Aventara's public configuration:
transactionOptions: { timeout: 10_000, isolationLevel: "Serializable" },
});
const framework = await createFramework({ api: "/api", adapter });The caller declares provider and driver. The adapter does not inspect client internals to guess
them: the driver decides whether transactions are advertised at all, and a wrong guess would be worse
than a declaration that is checked.
createPrismaAdapter is async. Construction verifies the discovery artifact, and the digest it
checks is a crypto.subtle promise, so every check runs once, here, before an adapter exists.
describePrismaAdapter: the same facts, with no client
describePrismaAdapter({ discovery, provider, driver }) returns the adapter's passive facts: the model,
the driver's transaction capability, the referential actions in Aventara's words and the computed
fields the generated types were written with. It constructs nothing and connects to nothing, so
aventara-build (@aventara/build) compiles the Contracts from it at build time. Spread into
createPrismaAdapter with the client, it is the adapter's options, and the adapter states exactly the
facts the description does:
// src/aventara.adapter.ts
export default describePrismaAdapter({
discovery,
provider: "postgresql",
driver: "@prisma/adapter-pg",
});
// where the framework is created
import description from "./aventara.adapter.js";
const adapter = await createPrismaAdapter({ ...description, client });createPrismaAdapter still verifies the artifact and checks the client against it; the description
does neither, because it has no client to check.
maxWait, timeout and isolationLevel are adapter-owned
They are provider concepts. Core's Adapter.transaction(work) interface carries no options and will
not grow any, so these live on createPrismaAdapter({ transactionOptions }) and are not Aventara public
configuration. Prisma's own defaults apply when they are absent: maxWait 2000 ms, timeout 5000 ms.
The supported isolation levels come from the declared provider. PostgreSQL admits ReadUncommitted,
ReadCommitted, RepeatableRead and Serializable; SQLite admits Serializable only. Asking for a
level the provider does not support fails the transaction; see P2028.
The generate step, and the artifact it emits
Discovery runs once, at generate time, and writes a plain-data artifact. At runtime the adapter loads that artifact, verifies it, and re-derives nothing.
The step ships with the package, as a binary and as a second export:
prisma generate # your client, as usual
npx aventara-prisma7-generate \
--schema prisma/schema.prisma \
--client src/generated \
--provider postgresql \
--driver @prisma/adapter-pg \
--out src/discovery.artifact.ts \
--config src/aventara.config.ts--provider and --driver are declared, never sniffed. The provider is recorded in the artifact, and the
step refuses a --provider that the schema's datasource block contradicts (provider = "sqlite" with
--provider postgresql), naming the flag to pass. The driver decides AvAdapterModel.transactions, a driver fact the schema cannot state. Declare the driver
the application will actually construct the adapter with. The two cannot drift silently, but they can
disagree, and core refuses construction when they do.
The step runs the Prisma 7 CLI itself: @prisma/prisma7 (bin prisma7, the Prisma 7 CLI under its own
name) when it is installed, otherwise prisma only when its version is in this package's declared
prisma range. npm i prisma now installs a Prisma 8 release candidate, so a project that has Prisma 8
installed keeps @prisma/prisma7 beside it. With neither usable, the step refuses in one sentence naming
both. TypeScript 6 is supported; TypeScript 7, which has no classic compiler API, is refused in one
sentence.
--config is optional. It names your Aventara configuration module (src/aventara.config.ts). The step
neither reads nor runs it: aventara-build (@aventara/build), which aventara:prepare runs right after
this step, reads the configuration's TypeScript type and writes which fields its compute.create and
compute.update stages fill, per layer, to computed-fields.generated.ts beside the artifact (the
directory the configuration imports its Resource tree from). The artifact imports that sibling file: computedFields (also discovery.computedFields) and its type, which the
model's type carries. A variant's guards, pipes and filters, which run before write compute, may then
leave exactly those fields out of a create; every other required field stays required. On a fresh install
the file is not there until aventara-build has run. Without --config, or when the configuration is not
a literal where a compute stage is written (aventara-build prints where), the artifact states nothing and
those stages may leave any create field out. When the framework starts, it compares the statement with
the configuration and warns (AVENTARA_STALE_GENERATED_TYPES) if you changed a compute stage without
running aventara:prepare again.
--out must not be inside --client: Prisma's next prisma generate clears that directory and would
delete the artifact. The step refuses such an --out, and one that names a directory, in one sentence
before Prisma runs.
The same step can run from code, for a project that already has a build script:
import { generatePrismaDiscoveryArtifact } from "@aventara/prisma7-adapter/generate";
await generatePrismaDiscoveryArtifact({
schema: "prisma/schema.prisma",
clientDirectory: "src/generated",
provider: "postgresql",
driver: "@prisma/adapter-pg",
artifactFile: "src/discovery.artifact.ts",
});The two entries have different dependencies. @aventara/prisma7-adapter depends on @aventara/core
and your generated client. @aventara/prisma7-adapter/generate also needs typescript and prisma,
which are declared as optional peer dependencies. They are not installed for you, and nothing reachable
from the runtime entry imports either, so no compiler and no Prisma tooling enters a deployed runtime.
If you call the generate entry, have both in your project's devDependencies; a project that generates
a Prisma client already does.
The step reads two sources at the same time, the schema.prisma text and the generated client's own
public types, and cross-checks them against each other. It emits a TypeScript module exporting one
discovery value with eight members:
- the canonical
AvAdapterModel(scalars, enums, Resources with fields, identifiers, available operations and create requirements, plus thetransactionscapability and thereferenceWriteslimit); - the Prisma addressing each Resource is reached by;
- the resolved referential actions per Resource;
- a record of the generated client it was read from (each model and its field references);
- a
modelHash; - the
prismaVersionand databaseproviderit was generated against; - the artifact's format (
regenerate_with_aventara_prepare).
Each Resource's create requirements are what a create cannot do without, read off the question marks
of the generated create inputs: required lists the columns with no default (["title"]), and
oneOf each required relation with the fields that hold its reference ([["author", "authorId"]]:
write author, or authorId, not both). parentSupplies says which of them a nested create gets
from its parent ({ "Author": { "posts": ["author", "authorId"] } }). They are part of the model, so
they reach every Contract and the generated client: a create that leaves one out is a type error, and
at runtime, after your pipes and computed behaviours have run, it is answered A2004 with a V1000
issue at its path (["arguments", "data", "title"], or ["arguments", "data", "author"] for a
required relation) before the database can refuse it.
The module also exports the model's type by name, AvModel (typeof model is that type). A
project that emits declarations (Nest's declaration: true) writes that name wherever an exported
declaration holds the model, such as the scaffold's aventaraConfig(), an AppFramework alias or
defineResource<typeof model>(), so those declarations stay under a kilobyte at any schema size. Before
the name, each mention wrote the whole model out, about 0.5 MB over 50 models.
modelHash digests the model and nothing else. The addressing table, the referential actions and the
client record sit beside it and outside the hash. A database-side rename or a foreign-key action
changes no semantics; inside the hash, either would move the Contract identity and force every
generated client to be regenerated for nothing.
On the runtime path there is no TypeScript program, no PSL parser and no Prisma tooling. The loader
reads no file: the artifact arrives as a value you import. The adapter reads one: the installed
@prisma/client's package.json, for its version.
At construction, before the application listens, the adapter checks three things and fails hard with an instruction to regenerate if any is wrong:
the artifact matches itself: the
modelHashover its own canonicalized model;the installed
@prisma/clientmajor is the one the artifact was generated against. The version is read from@prisma/client/package.json, resolved from the adapter's own location and then from the working directory. Where it cannot be read (a runtime without Node's module loader, a bundle deployed without@prisma/client), the adapter starts and emits a warning, codeAVENTARA_PRISMA_VERSION_UNCHECKED, saying the majors were not compared;installedPrismaVersionoverrides the version read;the Prisma client you pass is the one the artifact was generated from: the same models, and the same field references (
prisma.<model>.fields) on each. A schema change followed byprisma generatebut not by the generate step (a model or field added, removed or renamed) is refused in one sentence naming what differs:AvPrismaDiscoveryArtifactError: The Prisma client has changed since the discovery artifact was generated (model `Comment` is in the client and not in the artifact): run `npm run aventara:prepare` (the adapter's `aventara-prisma7-generate`) to regenerate it, then restart.A value with no recognisable model delegate (a test double, a deep mock) is not compared.
The client check does not see a change that leaves every model and field name in place, such as a
type, a default or an attribute. Run the generate step in the same build that runs prisma generate
(a project set up by aventara does, from postinstall and aventara:prepare), and deploy the
artifact with the schema and client it came from.
What is advertised, what is not, and why
Operation availability is derived, never assumed. Every omission is decided in one place, from three facts: the generated delegate's own method set, the Resource's identifiers, and the driver's transaction capability.
For a typical schema the compiled Contracts advertise fifteen distinct family.variant operations,
identically on all three Contract scopes. delete.many is not one of them.
delete.many is not offered: use delete.count
Prisma 7.10.0 has no deleteManyAndReturn. delete.many owes its caller the deleted rows, and no
provider call returns them, so the variant is omitted from every compiled Contract instead of being
emulated. Core refuses a request for it with A2002 UNKNOWN_OPERATION before the adapter is reached,
and no statement is issued.
To delete a filtered set, use delete.count. It answers a number, including zero.
Provider-gated capabilities are read from the client's own types, never from a provider table
No module in this package branches on provider. A capability is advertised when the generated
client's own public types admit it. The provider name is not even in scope where capabilities are
derived, so if (provider === …) is a compile error. A provider table would be a second source of
truth, and it would drift one Prisma release at a time.
On PostgreSQL and SQLite, read from the generated types:
| Capability | PostgreSQL | SQLite | Read from |
|---|---|---|---|
| Scalar list fields | advertised | not advertised: the connector rejects a list of primitives at schema validation | the schema's own validation plus the generated field set |
| JSON path filter | advertised | omitted: the operand is a JSONPath string here and string[] there, and one protocol name cannot carry both | the written operand shape on JsonFilter.path |
| Case-insensitive mode, JSON ordering operators, field-reference in/notIn operands | as the generated filter types admit | as the generated filter types admit | <Kind>Filter's own members |
| Explicit null ordering | advertised | advertised | SortOrderInput's admission, per field |
| create.many / update.many (row-returning bulk writes) | advertised | advertised | createManyAndReturn / updateManyAndReturn on the delegate |
| Transactions | "interactive" | "interactive" | the driver, not the provider (see below) |
Nothing beyond PostgreSQL and SQLite is measured. Cloudflare D1 is SQLite through @prisma/adapter-d1:
it reads the same generated client, so its capabilities are SQLite's, and it is measured in process
through Miniflare's D1 binding, where it differs from SQLite in two ways (no transactions, and a bigint
within ±(2^53 − 1); see below). MySQL, SQL Server, MongoDB and CockroachDB were not generated against.
Because every capability is read from the client you actually generated, a provider that lacks
something advertises less instead of failing at runtime, but this release makes no claim about those
providers.
update.many and create.many cannot project a to-many relation
On update.many and create.many, Prisma's row-returning bulk writes reach to-one relations only.
The generated *SelectUpdateManyAndReturn / *IncludeUpdateManyAndReturn admit the scalars plus a
to-one relation and carry no to-many relation and no $count, where the unrestricted *Include
carries both.
A to-one projection on update.many works. A projection reaching a to-many relation or $count on
these two variants is refused before any statement runs: A2004 (a validation error) with the issue
V1008, and nothing is written. The Contract advertises each relation's projection per position, and
on these two variants the position the bulk result is read in does not include to-many relations.
Keep update.many/create.many projections to scalars and to-one relations, and read to-many
relations with a separate find. The adapter does not fill the gap with a follow-up read, which would
add a statement and a new consistency window to a bulk write.
update.first and delete.first are framework-manufactured conveniences
Read this section before using either.
Aventara's first-style write takes a general filter, lets the ordering decide which record is "first",
limits the effect to that one record, and returns the affected record, all in one call. ORMs almost
never offer this, because SQL splits the ingredients between dialects: UPDATE … ORDER BY … LIMIT is
a MySQL/MariaDB extension while RETURNING is PostgreSQL/SQLite/MSSQL, so whichever dialect grants the
limit denies the returning. Prisma 7.10.0's operation set has no updateFirst and no deleteFirst at
all.
The framework builds these two operations out of two statements, inside one adapter-internal transaction:
findFirstwith the caller'swhereandorderBy, selecting the Resource's identifier. TheorderByselects which record; ordering and single-record selection are natural on a read. No match answersA1001 NO_MATCHwithnulldata, and nothing is written.update/deletenarrowed to the identifier the read returned, with the caller's original predicate re-asserted alongside it, carrying the caller's own projection, so the record the contract owes comes back from the write itself. Nothing extra is read, and nothing is stripped.
They are therefore non-atomic, and they have a defined conflict outcome. The Contract's own vocabulary calls them manufactured, non-atomic variants, and neither is ever described as atomic.
What re-assertion closes, which race that is, and the residual it leaves
Re-asserting the caller's predicate on the write closes the wrong-record race. Without it, the write
would go by identifier alone and could change a record that no longer matches what the caller asked
for. With it, the write can only reach a record the read selected. A record that left the predicate in
between is detected instead of changed: it answers A2014 STALE_SELECTION (409) with nothing written,
and a retry may succeed.
The published guarantee is: the record written is one the read selected, or nothing is written and the caller is told.
Re-assertion does not close stale firstness, and nothing can. Between the read and the write another
writer can insert or update a record that sorts ahead of the selected one and still matches the
original predicate. Neither statement sees it (the read never saw it, and the re-asserted write
succeeds against the record it was given), so update.first can change a record that is no longer the
first ordered match and report ordinary success. Nothing detects or reports this, and no re-assertion
can, because the missing fact is about a record that was never read.
This is a documented, permitted property of a framework-manufactured variant. Serializable isolation
does not fix it. Core's Adapter.transaction(work) interface carries no isolation parameter, so an
adapter cannot set a level for a manufactured variant or for a plan, and a provider transaction's
level is fixed before its first statement, so a per-operation escalation cannot be expressed at all.
SQLite admits Serializable only, and the residual persists there regardless.
If your operation's correctness depends on the record still being the first ordered match at the
moment of the write, do not use update.first/delete.first. Select the record yourself and use
update.unique/delete.unique on an identifier, inside a transaction plan whose other operations
make the invariant you need checkable.
Wherever this README says a race is closed, it names which race.
Why the write is by identifier and not by Prisma's limit
Prisma does offer a top-level limit on updateMany, deleteMany and updateManyAndReturn in
7.10.0, on every model on PostgreSQL and SQLite. Aventara exposes what the ORM offers, so this is not
a choice to hide it.
limit cannot express this operation. None of those three argument types carries an orderBy, so
limit: 1 affects an arbitrary matching row, and two identical calls can affect different rows. The
identifier from the ordered read is what makes the write deterministic.
Aventara does not currently expose an arbitrary affected-row cap. If it ever does, its name will make clear that it is not an ordered window.
Errors
Branch on the code, never on the message text
Every failure this adapter originates carries framework-authored text and nothing read off the Prisma
error. No table name, constraint name, column name, SQL fragment, connection string, file path or
stack reaches a caller. The normalizer never reads the provider's message, so there is no provider
string in scope to forward. This package's own conformance gate sweeps the whole serialized envelope
of every failing path for provider markers.
So branch on the code. Message text is framework prose, may change, and says less than the provider did.
The three conflict codes
Three different situations each have their own code.
| Code | Means | Retry? |
|---|---|---|
| A2008 CONFLICT | The operation would violate a uniqueness or reference constraint the Resource declares. Raised from Prisma's P2002 and P2003. | No. Re-running the same request unchanged fails the same way. |
| A2013 CONCURRENT_MODIFICATION | Another operation modified the same state concurrently, so this operation was not applied and nothing was written. Raised from Prisma's P2034. | Yes, the same request may succeed. The framework performs no automatic retry. |
| A2014 STALE_SELECTION | The state this operation had selected changed before its effect could be applied, so nothing was written. Raised by the adapter's update.first and delete.first. | Yes, the same request may succeed. No automatic retry. |
A2008 was narrowed to the constraint case, not repurposed. If you previously treated A2008 as "any
conflict", the two situations that left it now have their own codes.
A2008's narrowed meaning lives in the specification, not in the wire text. The emitted message stays
"The operation conflicts with the current state of the resource." The words "uniqueness" and
"constraint" are the provider's own words for this failure, and the sanitization rule bans them from a
provider-originated envelope because it cannot tell a framework-authored "constraint" from Prisma's.
So this README can say A2008 means a uniqueness or reference constraint, but the envelope does not
say so, and nothing should be parsed out of it.
A write conflict and a deadlock share A2013. Both arrive as P2034 with identical meta keys and
differ only in a provider-owned code (40001 versus 40P01) that never crosses the boundary. They
cannot be told apart here, so they get one outcome.
None of the three carries cause.issues
A2008, A2013 and A2014 pair with no validation code, and cause.issues is always absent on
them. A conflict is not a Framework Validation Layer failure: validation codes determine the outcome
code and roll up only to the validation outcomes, so a conflict validation code would have no legal
rollup. Do not write a caller that expects issues on a conflict.
P2034 is never retried automatically
Neither Prisma nor this adapter retries a write conflict, and the adapter will not grow a retry. A transaction plan's operations run developer-authored pipeline callbacks, and re-running a plan re-runs those callbacks, including after hooks whose external side effects are not rollback-safe (see below). The caller makes a retry, after deciding its own operations are safe to repeat.
An after hook's external side effect inside a plan runs before the commit
Inside a transaction plan, a pipeline after hook runs inside the provider transaction, and therefore before the provider commits. If a later operation fails, the provider rolls back: the row is absent but the external effect has already happened. An email was sent, a queue message published or a cache written, for a record that does not exist.
The specification describes this behaviour, and this package's conformance gate asserts it against a real provider commit. The duty falls on the hook author: an after hook SHOULD be observational or idempotent, and MUST NOT be relied on to undo a mutation. If an effect must not happen unless the plan commits, do not perform it in an after hook.
P2028 covers seven situations, and three of them are separable
P2028 is Prisma 7's transaction code, and seven internal classes share it. The adapter answers
A3002 TRANSACTION_ERROR wherever it is met, and never reads the message text to decide anything.
What can be told apart from the error's structure, measured on PostgreSQL and SQLite:
| Situation | Code | meta |
|---|---|---|
| Transaction-timeout expiry | P2028 | { operation, timeout, timeTaken }: the expiry is identifiable |
| maxWait acquisition timeout | P2028 | empty: identifiable by that emptiness, and by nothing finer |
| Valid-but-unsupported isolation level (e.g. Snapshot on either engine) | P2028 | { isolationLevel } |
| An isolation level outside Prisma's own set (e.g. "Bogus") | P2023 | { providedIsolationLevel }, a different code entirely |
The other four classes (transaction not found, already committed, already rolled back, and an internal
consistency failure) carry no meta at all and differ only in their message text. Reading error text
to tell outcomes apart is not allowed here, so those four cannot be told apart, and the acquisition
timeout cannot be told apart from them. That limit is Prisma's, and it is recorded here so nobody later
works around it by parsing messages.
A P2028 that ends the transaction rejects out of $transaction and becomes A3002. A P2028
raised by a delegate call (or a raw statement) inside the callback, such as a step that runs after the
timeout passed, is in the closed code table and also becomes A3002, attributed to that operation
(cause.operation). In rc.1 that second case was unclassified and answered A3001.
Everything unrecognised becomes A3001
The code table contains only codes that were induced against a real engine on a real schema. A Prisma
code the table does not contain is left to core's adapter boundary, which answers A3001 ADAPTER_ERROR
with core's own text and sends the raw error to the diagnostics sink. Adding a row later is additive;
a guessed row, once shipped, cannot be taken back.
Prisma 7 only
The code table's keys are P-codes, which are Prisma 7.x's error surface. Prisma 8 (a release candidate
in October 2026) is a rewrite: no @prisma/client, no prisma generate, no driver adapters, and dotted
NAMESPACE.SUBCODE errors with no P-codes yet. A Prisma 8 adapter will therefore be a new adapter
package, @aventara/prisma8-adapter, instead of a re-map of this one. A project keeping Prisma 7 beside
a Prisma 8 install runs the Prisma 7 CLI as @prisma/prisma7 (bin prisma7), which the generate step
prefers. The bound is enforced: the package's own suite reads the installed @prisma/client's version
and fails if the major is not 7, and the discovery artifact records the major it was generated
against.
Transactions
Adapter.transaction is mounted exactly when the declared driver supports an interactive
transaction. It is one client.$transaction(async (tx) => …) per Aventara transaction plan:
- It uses the callback form only, never the array form. An Aventara plan is sequential by contract, so operation n may consume operation n − 1's result, and the second call's arguments do not exist until the first has answered. The array form takes every call up front, so it cannot carry a plan at all, and the interface this adapter holds cannot even represent it.
- The root client is unreachable from inside the callback: the executor core is handed closes over the transaction client and nothing else.
- The value the callback threw is rethrown by identity. The adapter adds no
try, nocatchand no wrapper, because core's transaction interface requires that, and wrapping would turn every plan failure into an integrity failure.
A first-style variant inside a plan reuses the plan's transaction instead of opening a nested one, so its two statements roll back with the plan, not independently.
Prisma swallows its own rollback failures: the rollback's rejection reaches an internal debug logger and never the caller. This adapter therefore never claims a rollback succeeded, because it cannot observe that it did. A commit failure is different: it rejects like a callback throw.
transactions is a driver fact, and it reaches the ClientContract hash
@prisma/adapter-d1 declares transactions: "none": Prisma's client refuses an interactive
$transaction on D1 itself. Prisma still opens its own implicit transaction for a nested write (a
$create under a relation, a $connect), and the D1 driver runs it as single statements, so a nested
write is not atomic on D1: when a later statement fails, the rows the earlier ones wrote are kept.
@prisma/adapter-pg and @prisma/adapter-better-sqlite3 declare "interactive", observed by running
one.
Plan for the consequence. The transactions capability is part of the ClientContract hash, and a
generated client fails hard on a hash mismatch. So one schema, one framework configuration and two
deployment drivers produce two ClientContracts, two hashes and two generated clients, and a client
generated against a "none" deployment will not run against an "interactive" one, or the reverse.
Generate your client against the deployment's own driver, and treat the driver as part of the deployment's identity, not as a connection detail. If you deploy the same application over D1 and over PostgreSQL, those are two deployments with two generated clients.
An artifact and a driver cannot drift silently either: an artifact that says "interactive" loaded by
an adapter whose declared driver says "none" makes framework construction fail with a capability
mismatch.
Referential actions
The artifact carries the referential actions, the adapter states them to core, and core refuses a transaction plan that depends on rows a cascade removed.
The generate step resolves each relation edge's referential actions, including the defaults Prisma
applies when the PSL argument is absent. It cross-checks them while reading the schema, and emits them
as the artifact's referentialActions member:
discovery.referentialActions;
// {
// Shop: { owningEdges: [] },
// Item: { owningEdges: [{ field: "shop", target: "Shop",
// cardinality: "one", referenceFields: ["shopId"],
// onDelete: "cascade", onUpdate: "cascade" }] },
// Clerk: { owningEdges: [{ field: "shop", target: "Shop",
// cardinality: "one", referenceFields: ["shopId"],
// onDelete: "set-null", onUpdate: "cascade" }] },
// }Read it in one direction. onDelete is declared on the child and fires when the parent is deleted, so
an entry under Resource R says deleting a target row does onDelete to rows of R. To answer
"what does deleting a Shop do?", scan every Resource's owningEdges for target === "Shop" and
read the enclosing Resource key: above, that is Item (cascade) and Clerk (set-null). Both ends of
that sentence are Resource names and nothing in the shape tells them apart, so reading it backwards
produces no error, only a plan that reasons backwards.
referenceFields are the edge's @relation(fields: […]), the scalars that hold the reference (the relation field's referenceFields in the model; its references are the target's fields). A record
that writes both a relation and one of its reference fields (shopId and shop: { $connect: … },
whether the relation is required or optional) is refused before the database is reached: A2004 with
a V1012 issue at the relation, message Set "shopId" or "shop", not both. The reference fields are
also what a required relation may be written through instead of the relation itself.
Prisma takes one record's relations either all as relation writes or all through their reference
fields (its checked and unchecked inputs), so every artifact's model states referenceWrites: "uniform",
and every Contract compiled from it carries the same member. A record that writes shopId beside
another relation's write (supplier: { $connect: … }) is refused before the database is reached:
A2004 with a V1012 issue at the relation write. A reference field beside a back-reference (a
relation that holds no reference on that side, such as a shop's items) mixes nothing and is
accepted.
Only owning edges appear. PSL forbids the non-owning side of a relation from declaring an action, so a
back-reference's answer is its partner's and has no entry of its own. Both actions are resolved: an
absent PSL argument means Prisma's effective default (set-null where every foreign-key scalar is
optional, restrict otherwise, cascade on update), on both PostgreSQL and SQLite. An action spelling
the reader cannot name stops the generate step before it reaches the artifact.
Neither the model nor the compiled Contracts carry a referential action or a reference field. The edges
sit beside the model and outside modelHash, so they move no Contract value and no hash.
createPrismaAdapter states them to core through the adapter's optional referentialActions member,
translated one to one into core's five words (mirroring Prisma's and SQL's actions, direction
unchanged):
| Prisma action | core's word | deleting a target row does this to rows of the enclosing Resource |
|---|---|---|
| cascade | cascade | deletes them, and the closure recurses into them |
| restrict | restrict | refuses the delete while they reference it |
| no-action | noAction | refuses the delete while they reference it; identical to restrict on both engines, because NoAction is not DEFERRABLE on either |
| set-null | setNull | keeps them, the reference set to null |
| set-default | setDefault | keeps them, the reference set to its column default |
Only cascade drives core behaviour (the V1019 closure below). restrict, noAction, setNull and
setDefault are carried as informational adapter facts: the adapter states what the schema says
instead of reducing it to what core currently reads.
onUpdate is not carried, because core plans the delete closure only.
Core builds the transitive closure of cascade edges once, at framework construction. The transaction
planner refuses a step that depends on rows an earlier step's cascade removed (its own Resource, or one
its arguments reach through a relation) with A2004/V1019 "Transaction step depends on a row
removed by an earlier step's cascade", before any provider statement is issued. For example, with
Metric.author (onDelete: Cascade) as the one cascade edge, deleting an Author and then reading
its Metric rows is refused. Deleting an Author and then reading Post (Restrict), or a
Category and then Post (SetNull), is not. The refusal is per Resource, since a plan cannot know
which rows a delete will match, and it is plan-scoped: a single delete that cascades runs.
Raw SQL: developer statements only, always bound
Aventara never writes raw SQL itself. Every standard operation goes through Prisma's typed delegate
surface. $queryRawUnsafe, $executeRaw, $executeRawUnsafe, findRaw, aggregateRaw and
$runCommandRaw appear nowhere in this package, and no raw action is representable in the plan the
translator builds; a test pins that plan to the closed set of thirteen Prisma actions the adapter may
issue.
Developer SQL reaches the database two ways, both written as a tagged template: framework.appTx.$sql,
a step of an application transaction, and a pipeline stage's ctx.$sql, run at once.
const post = framework.appTx.Post.create.one({ data: { title, authorId }, select: ["id"] });
const job = framework.appTx.$sql<{ msg_id: bigint }>`
select pgmq.send('jobs', jsonb_build_object('postId', ${post.$ref("id")}))`;
const [created, rows] = await framework.transaction([post, job]);
// rows is { msg_id: bigint }[]The adapter runs it with $queryRaw in its tagged-template form. Inside a transaction (an
appTx.$sql step, or a ctx.$sql in a step, a forced call or a ctx.$transaction) it runs on the
interactive transaction client, so the statement commits or rolls back with the transaction. A
ctx.$sql made outside any transaction runs on the base client as a plain statement: no BEGIN is
sent around it, so it needs no interactive transaction and works with @prisma/adapter-d1 too. The
adapter declares sqlStatements with every driver. Every interpolated value is
a bound parameter, never SQL text, so the text is static and a table or column name cannot come from
a value. What it binds, measured on both engines with 7.10.0:
- a JSON value (a plain object or array) is bound as its JSON text: cast it,
${value}::jsonbon PostgreSQL, or read it withjson_extract(${value}, '$.k')on SQLite; - an Aventara
AvDecimalis bound as its exact decimal text; aDate,bigint,Uint8Arrayand the primitives are bound as Prisma binds them; - a bare placeholder is the database's to type: PostgreSQL reads
select ${1}astext, so cast where the type matters.
The rows come back as $queryRaw reads them, except that a PostgreSQL numeric becomes Aventara's
AvDecimal. Prisma 7.10.0 cannot read a SQLite BLOB column out of a raw query; such a statement fails
with A3001.
A raw query's failure is always P2010 on 7.10.0, a syntax error and a constraint violation alike.
The adapter tells them apart by the driver adapter's own cause.kind, never by message text. A
uniqueness or foreign-key violation answers A2008, as it does through a delegate, and everything
else (a syntax error, a missing table, a null violation) answers A3001 with the raw error in
diagnostics. Either way the whole plan rolls back.
appTx.$sql exists only in process. clientTx has no SQL step, the ClientContract carries nothing of
it, and no /_transactions request can express one. A later step cannot $ref a SQL step's rows
(A2007/V1010). No guard, pipe, hook, interceptor or filter runs around a SQL step, since it has no
Resource or operation for one to wrap.
Other behaviour
$counton a paginated relation reports the count before pagination. On both engines, a relation's_countis unaffected bytake/skip/cursorapplied to the same relation in the same query. A filter on the relation does affect it.limit/offset/cursorare the public names. Prisma'stake/skipnever surface.- Decimal values are converted in both directions. Prisma's bundled decimal object is not an Aventara public type and does not reach a caller as one.
- Values and filter operands reach Prisma as given: a
contains,startsWithorendsWithoperand is the engine'sLIKEpattern (%and_included), and a column stores, rounds or widens a value as the engine does. A value PostgreSQL refuses (too long for its column, out of its range, U+0000 in text, aLIKEpattern ending in\) answersA2004with one issue at["arguments"](V1004orV1001), notA3001. - On D1, a
BigIntvalue must lie within ±(2^53 − 1) (±9,007,199,254,740,991), not the full signed 64-bit range: the D1 driver binds a bigint as a JavaScript number and refuses one beyond that before the statement runs. A value beyond it, written, compared in a filter or used in an$increment, answersA2004with oneV1004issue at the value (["arguments", "data", "views"]), and the statement that carries it writes nothing. Actx.$sqlstatement binding such a value answersA3001: its SQL is yours. - A record written through a relation cannot write that relation, or its reference fields, back: Prisma's nested
input for it leaves them out (a post created in
author.postshas noauthororauthorId; a category created in a post'scategoryhas noposts). The artifact states each relation's other end (inverse, paired by relation name), the fields that hold its reference (referenceFields, from@relation(fields: …)) and the target's fields they hold, pair by pair (references, from@relation(references: …), which@aventara/corekeeps in the Core Contract only, for a policy engine), and no relation writable at the"loopback"position, so@aventara/corerefuses such a write before any guard or SQL,A2004with aV1012issue at the field written back, and the types do not offer it. Where a layer hides the other end, Prisma's client refuses the write instead and the adapter answers that refusal the same way. - Count variants return numbers, including zero.
create.manyandupdate.manyreturn arrays. find.unique,update.uniqueanddelete.uniqueanswerA2003 NOT_FOUNDon a missing target;find.first,update.firstanddelete.firstanswerA1001 NO_MATCHwithnulldata. The distinction is core's, not the adapter's.
Not covered
- every provider except PostgreSQL and SQLite;
- implicit many-to-many relations,
type/view/ composite blocks and multi-schema. Each arrives as a recorded unknown that stops the generate step instead of mapping silently; @defaultforms beyonduuid(),cuid(),autoincrement(),now()anddbgenerated(…). An unmatched form is recorded as a defaulted field plus an unknown that fails the generate step, so an unconfirmed mapping cannot become a wrong model;- whether Neon-HTTP or PlanetScale-HTTP support interactive transactions in Prisma 7. Those drivers
are absent from the driver set instead of declared
"none", because an entry with no evidence would be a guess.
License
PolyForm Shield 1.0.0 with an additional permission. It is free to use, including in commercial applications, but you
may not use it to build a product that competes with Aventara itself. See LICENSE and
LICENSE-ADDITIONAL-PERMISSION.md.
