prisma-model-extension
v0.4.0
Published
Model methods and computed fields for Prisma, discovered from the filesystem and applied without a client extension
Readme
prisma-model-extension
Model methods and derived columns for Prisma, discovered from the filesystem and applied without registering a client extension.
What it is
A model becomes a folder that holds everything about it: its schema, its functions, its derived columns.
src/models/Post/
schema.prisma
methods/
index.ts
queries.ts
mutations.ts
computed.tsA generator reads that layout during prisma generate and types a client where
every exported function is a method on its delegate, and every exported
computed.* entry is a column on its rows:
await database.post.findPublished();
await database.post.findMany({ select: { id: true, excerpt: true } });The folder name is the model name, so nothing is declared twice.
The motivation. Prisma gives you a delegate per model but nowhere to put the
logic that belongs to it, so it drifts into a service layer that has to be kept
in step with the schema by hand. $extends can carry both methods and computed
fields, but registering any extension makes Prisma route every row of every
result through applyResultExtensions, whether or not a computed field was
asked for. You pay on every query for a feature used by a few.
What it does about it. Methods are layered with a proxy, and derived columns
are applied only to the queries that request them, so a query that touches
neither costs nothing measurable. Model functions resolve the client through
AsyncLocalStorage, so one called inside $transaction runs inside that
transaction without being handed one. Everything stays typed from the schema:
compute only sees the columns it declared, and its return type becomes the
column's type.
Install
Requirements
- Node 20 or newer
- Prisma 7
yarn add prisma-model-extension1. Add the generator
generator models {
provider = "prisma-model-extension"
output = "../../.generated"
modelsPath = "src/models"
importRoot = "@/src/models"
clientImport = "@/.generated/client"
}The provider is this package's binary, which Prisma finds on PATH. Your
package manager puts node_modules/.bin there for npx prisma generate,
yarn prisma generate, any package.json script and the @prisma/client
postinstall. A globally installed prisma invoked on its own is the one case
that cannot see it, and wants the explicit path instead:
provider = "node ./node_modules/prisma-model-extension/dist/generator.mjs"output is required, so nothing is written anywhere the schema did not name. The
schema has to be a folder with one directory per model, since a model's methods
are found beside its own .prisma file; modelsPath points elsewhere if your
models live outside the schema folder.
If your project is ESM and resolves with node16 or nodenext, set
importFileExtension = "js". TypeScript requires the extension on a relative
import there, and without it the generated file will not compile.
| Key | Default | Does |
|---|---|---|
| output | required | Where the two generated files are written |
| modelsPath | the schema folder | The folder holding one directory per model |
| importRoot | @/src/models | Import specifier that folder is reachable at |
| clientImport | @prisma/client | Where PrismaClient and Prisma come from |
| runtimeImport | prisma-model-extension | Where this package is imported from |
| importFileExtension | none | Extension to append to a model import, e.g. js for ESM |
| fileName | modelExtensions | Name of the generated client file |
| helperName | computed | Name of the generated authoring helper |
| detectEscapes | unset | Schema-wide default for escape reporting |
| baseMethods | on | "false" layers no base methods onto any model |
2. Generate
prisma generate writes two files into output:
modelExtensions.ts:withModels, theDatabasetype it answers with, and the relation map the layer walkscomputed.ts: the helper a model file uses to declare a derived column
Both are regenerated from scratch every run, so keep output gitignored.
3. Build the client once
// core/database/index.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/.generated/client";
import { withModels } from "@/.generated/modelExtensions";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const database = withModels(new PrismaClient({ adapter }));
export default database;Prisma's own constructor builds the client, and withModels layers the models
over it. What comes back is the Database type, which is exported too for
annotating anything that takes one.
withModels is the only way in, so there is no half-layered client to hold by
mistake. Its second argument is where escape reporting
is turned on or off for this client.
4. Write a model
A model is methods.ts, or a methods/ folder whose index.ts re-exports the
rest. That module is what the generator reads, so anything not re-exported from
it is not registered:
// src/models/Post/methods/index.ts
export * from "./queries";
export * from "./mutations";
export * from "./computed";Exported functions become methods on the delegate. They reach the database through the module above, which is what keeps them inside a transaction:
// src/models/Post/methods/queries.ts
import database from "@/core/database";
export const findPublished = () =>
database.post.findMany({ where: { published: true } });await database.post.findPublished();
await database.$transaction(async () => {
await database.post.findPublished(); // runs inside the transaction
});Exported computed.* entries become derived columns. needs declares the
columns compute is given, and what compute returns is the column's type:
// src/models/Post/methods/computed.ts
import { computed } from "@/.generated/computed";
export const excerpt = computed.post({
needs: { body: true },
compute: (post) => post.body.slice(0, 10),
});const post = await database.post.findFirst({
select: { id: true, excerpt: true },
});
// { id: "1", excerpt: "hello worl" }Anything else a model exports, a constant say, is passed through onto the
delegate untouched. A model with no methods module at all is left alone.
How it works
Why not a Prisma client extension
Registering any extension makes Prisma walk every row of every result through
applyResultExtensions, whether or not a computed field was requested. Methods
are layered with a proxy instead, and computed fields are applied only to
queries that ask for them, so an untouched query costs nothing.
Measured against a real database, 2000 rows: whole rows +12%, narrow selects +6%.
yarn bench measures the layer on its own, against a client that answers
instantly, so what is left is the layer. A delegate is built once, when it is
first reached: every member the client offers is bound onto a plain object, and
the model's own methods and its base methods are defined over the top. Reading
one after that is an ordinary property access, not a proxy trap.
A model that carries base methods pays about 4ns per query, and a base
method a further 2–10ns over the lookup it defers to. Delegate resolution and
client construction are unchanged. baseMethods = "false" removes it entirely.
A model is watched only when a derived column is reachable from it: its own,
or one a relation leads to, at any depth, because select and include nest
without limit. Everything else is handed Prisma's own method untouched, and a
model with no methods of its own is not layered at all. src/reach.ts works
that out once, as a fixpoint over the relation graph.
How much that saves depends entirely on the schema. A derived column in a quiet corner of the graph costs nothing anywhere else. A derived column on a model everything relates to reaches every model, and then every query pays.
What a watched model pays is about 700ns on a query that asks for no derived column, and about 900ns on one that does. That is the largest number in this library by some distance, and most of it is planning the selection rather than mapping any rows.
The promise wrapper is what keeps the fluent API working, so it goes on either way. One handler is shared across every wrapped promise, with each wrapper's plan held in a WeakMap keyed by the wrapper itself, so a query allocates the proxy and nothing else, and two wrappers over one promise still keep their own plans.
Because a delegate is settled when it is first reached, a member added to the
client afterwards is not picked up, and patching a layered delegate does not
reach the client underneath. Both are covered in tests/delegate-shape.test.ts.
Base methods
A model whose schema gives it a plain scalar id gets two methods it never had
to write:
const file = await database.file.findById(id); // File | null
const sure = await database.file.findByIdOrThrow(id); // FileBoth defer to findUnique and findUniqueOrThrow underneath, so a derived
column still lands on the row, a selection still narrows, and a call inside
$transaction still runs in it. The second argument is everything findUnique
takes except where, and the result narrows exactly as it would there:
const row = await database.file.findByIdOrThrow(id, { select: { excerpt: true } });
// { excerpt: string }A model keyed by anything else gets neither. Give it a composite @@id, or an
@id on a column not called id, and the methods are absent from the type as
well as the client.
Every model, keyed or not, can also be looked up by one of its own columns:
await database.user.findBy("email", email);
await database.user.findByOrThrow("invite_token", hashed);The column name is checked against the schema, and the value against that
column's type. A third argument is anything findFirst takes, and a where
inside it is merged rather than replaced, which is how a second condition is
added:
await database.user.findBy("reset_token", hashed, {
where: { reset_expire_date: { gt: now } },
});These defer to findFirst and findFirstOrThrow, so any column works. A model
with a unique column also gets the pair that defers to findUnique, which is
the one Prisma batches within a tick:
await database.user.findUniqueBy("email", email);
await database.user.findUniqueByOrThrow("reset_token", hashed);The column is checked for existence, not for uniqueness. Prisma's
WhereUniqueInput is an AtLeast union that also admits non-unique columns as
extra filters, so no mapped type can single out the unique ones. Naming a
non-unique column compiles and then throws at query time. findBy has no such
edge, so reach for findUniqueBy only where you know the column is @unique.
A model that declares its own findById or findBy keeps it; a layered method
is only ever a default.
Turn the whole idea off for a client that should carry nothing it did not declare:
generator models {
baseMethods = "false"
}Which operations carry a derived column
Every operation that answers with rows, which is what Prisma's own result
extensions do: the five find variants, plus create, update, upsert,
delete, createManyAndReturn and updateManyAndReturn. The ones that answer
with a count or an aggregate are never touched, so createMany, updateMany,
deleteMany, count, aggregate and groupBy cost nothing.
What it does to a query
A derived column is never shown to Prisma. The selection is rewritten on the way down, at every depth, and the columns it needed are dropped again on the way back:
await database.post.findMany({ select: { id: true, excerpt: true } });
// Prisma sees { select: { id: true, body: true } }
// you get back [{ id: "1", excerpt: "hello worl" }]The same works inside include and select at any depth, and through the
fluent relation API:
await database.user.findUnique({ where }).posts({ select: { excerpt: true } });A selection returns exactly what it selected, as Prisma behaves. Under
include, or with no selection at all, every derived column is added. If Prisma
omits a column a derived column needs, that column is left out rather than
computed from a row that does not have it.
The arguments belong to the caller and are never written to, so a shared or frozen query object is safe to reuse. A selection is read by its own keys, the way Prisma enumerates one.
Working with other extensions
$extends is intercepted so this layer stays underneath whatever is applied on
top, in either order, and the layer keeps Prisma's own promise so the batch form
of $transaction and the fluent API keep working.
Two things worth knowing:
$extendskeeps the methods at runtime but not in the types. Prisma builds that return type from its own arguments alone. Cast it back withclient as typeof client & ModelExtensions. Re-applying the registry insideWithModelswas tried and rejected: it wins the model methods back and loses Prisma's own delegate types, which is worse.- Another extension's own queries are not seen by this layer. A model method
like
paginate()reads through the client it captured, which sits underneath, so those rows carry no derived columns. Rows read normally do.
When the async context is lost
A model function reaches the client through AsyncLocalStorage, so inside
$transaction it resolves to that transaction and everywhere else to the root
client. That context follows normal async work, but it is lost whenever a
closure made inside a transaction is invoked from outside it: a job queue
drained by a worker, an emitter registered before the transaction, anything
parked in a structure that outlives the call.
When that happens the query quietly runs on the root client, so it is not part of the transaction and will not roll back with it. To stop that going unnoticed, a query that resolves to the root client while a transaction is open is reported:
prisma-model-extension: a query on `user` resolved to the root client while 1
transaction was open.
The async context was probably lost, so this query is not part of the
transaction and will not roll back with it.It is a signal, not a proof: two overlapping requests look the same from here, which is why it is on by default only when NODE_ENV is not production.
Silence a deliberate one by saying so:
import { outsideTransaction } from "prisma-model-extension";
outsideTransaction(() => audit.record(event));Turn it off or on per client, or for the whole schema:
withModels(client, { detectEscapes: false });generator models {
detectEscapes = "false"
}Pass a function instead of a boolean to handle the report yourself, which is how you turn it into a thrown error in tests:
withModels(client, { detectEscapes: (escape) => { throw new Error(escape.stack); } });Each call site is reported once, so a leak in a loop does not flood the log.
Why the value is written onto the row
yarn strategies measures the alternatives, 5000 rows and two derived columns:
strategy apply read all stringify own key in JSON
assign (what the layer does) 0.35ms 0.51ms 1.87ms true true
own getters 4.78ms 1.30ms 6.55ms true true
shared prototype 0.63ms 0.28ms 1.36ms false false
create from prototype 0.29ms 0.29ms 1.01ms false falsePutting the columns on a prototype as getters is lazy and marginally faster to
apply, but the row stops being a plain object: the value is no longer an own
key, so JSON.stringify drops it, spreading drops it, and structuredClone
drops it. A derived column would silently vanish from an API response.
Prisma's own result extensions produce own, enumerable, serialisable keys, and
this layer matches that exactly. Defining them as own getters keeps the
semantics and is lazy, but defineProperty per field per row is 13× slower
to apply than an assignment. So the value is written onto the row.
Import order
The model files import your database module, which imports the generated file,
which imports the model files. That cycle resolves when the database module is
the entry point, and throws Cannot access 'withModels' before initialization
if something reaches the generated file first. Import the generated module from
one place only, and re-export from there.
Limits worth knowing
The selection rewrite is recursive, so it runs out of stack somewhere past 1500
levels of nesting, which is where Node's own structuredClone gives up too. It
throws where it is called rather than returning half a result. Nothing else here
has a depth or size limit: 100,000 rows, 1,000 column selections and 500 models
are all covered by the suite, and the heap stays flat across 300,000 queries.
Writing a base extension
One file declares the whole thing: how to spot the models it applies to, the type it adds to their delegate, and what it does at runtime.
// src/base/id.ts
export type ById<Delegate> = {
findById<A extends Without<Delegate, "findUnique">>(
id: string,
args?: A,
): Promise<Result<Delegate, A & { where: Filter<Delegate, "findUnique"> }, "findUnique">>;
};
export const id: Capability = {
name: "id",
match: (model) => model.fields.some((f) => f.name === "id" && f.isId),
alias: "ById",
methods: [{ name: "findById", build: keyed("findUnique") }],
};The type is ordinary TypeScript, checked by this package's own tsc, and the
generated file imports it by the name in alias. match runs against the
schema during generate, and args can hand the alias extra type arguments read
from that same schema, which is how ByUnique gets the model's unique columns.
Add the file, add it to CAPABILITIES in src/base/index.ts, and nothing else
changes.
Contributing
Layout
| File | Does |
|---|---|
| src/generator.mjs | The Prisma generator, as prisma generate drives it |
| src/sources.mjs | Builds the files it writes, without a schema or a process |
| src/computed.ts | The authoring surface for a derived column |
| src/runtime.ts | createModels, which wires everything below together |
| src/base/ | One file per base extension, each declaring itself in full |
| src/base/prisma.ts | Prisma's own type helpers, so an extension can use them |
| scripts/benchmark.ts | yarn bench, which measures the layer without a database |
| src/registry.ts | Model modules to methods and definitions |
| src/selection.ts | Rewrites a query so Prisma never sees a derived column |
| src/rows.ts | Puts the derived columns back on the rows |
| src/promise.ts | Keeps Prisma's own promise, fluent API and batch hook |
| src/client.ts | Layers the registry over a client and its delegates |
| tests/delegate-shape.test.ts | What a layered delegate looks like from outside |
| src/transactions.ts | The async context and the open transaction count |
| src/escapes.ts | Reports a query that escaped an open transaction |
| src/proxy.ts | The proxy primitives the layers share |
| scripts/build.mjs | yarn build, which bundles and verifies what ships |
| scripts/fixture.mjs | yarn fixture, which writes the real client the tests read |
| scripts/variant.mjs | One throwaway project per Prisma generator configuration |
| tests/prisma/ | The fixture schema, its cases, and the type assertions |
Prisma settings
Every PrismaClient option and every shape of generated client is covered by a
test that reads a real client, generated by the real CLI, over a real SQLite
file. yarn fixture writes what those tests read.
Two of Prisma's options change what a query answers with, because the
constructor infers them into PrismaClient's own type parameters:
| Option | Carried as | What the layer has to do |
|---|---|---|
| omit | OmitOpts, invariant | Name it on the client it takes, or every omitted column comes back |
| log | LogOpts, contravariant | Name it, or $on narrows to no event at all |
The rest (adapter, accelerateUrl, errorFormat, transactionOptions,
comments, queryPlanCacheMaxSize) do not reach the result types, and there is
a case asserting each one leaves them alone.
Two things are worth knowing:
$extendsdrops$onfrom the client it hands back, and the layer is built on$extends, so$onis named back on explicitly with the levels the client logs. It always worked at runtime; only the type was missing.- Prisma's own
Types.Public.Resultforwards three arguments toGetResult, whose fourth is the global omit, so a base method typed through the public helper cannot see it. The base extensions reachGetResultdirectly instead.
tests/variants.test.ts builds a throwaway project per Prisma generator
configuration (moduleFormat, importFileExtension, generatedFileExtension),
generates into it and typechecks it. Each case claims the layer reports what
Prisma reported rather than a literal, so a configuration Prisma cannot type on
its own is recorded as that rather than blamed on this package.
Build
yarn build typechecks src/, then bundles it into three minified files with
no comments and nothing else to resolve:
| File | Is |
|---|---|
| dist/index.js | The CommonJS entry point, bundled from src/index.ts |
| dist/index.d.ts | Every declaration, exporting only what src/index.ts does |
| dist/generator.mjs | The executable Prisma spawns, shebang intact |
@prisma/client and @prisma/generator-helper stay external, resolved from the
host project as the peer dependencies they are.
The build then verifies its own output, because bundling and minifying can break
things a typecheck never sees: it loads dist/index.js as a consumer would and
compares its exports against the documented surface, typechecks dist/index.d.ts
on its own, asks dist/generator.mjs for its manifest over the same JSON-RPC
Prisma uses, and parses all three looking for a comment that survived.
AI disclosure
The tests under tests/ were written with AI assistance. They run against a
fake client, so they need no database, but read them as claims to check rather
than as a specification to preserve. A test asserting the wrong thing is a bug
like any other.
License
Apache-2.0. Use it, fork it, ship it. Modified files have to carry a notice saying they were changed, and the NOTICE file travels with any distribution, including a fork released under a different name.
