npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.ts

A 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-extension

1. 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, the Database type it answers with, and the relation map the layer walks
  • computed.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);   // File

Both 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:

  • $extends keeps the methods at runtime but not in the types. Prisma builds that return type from its own arguments alone. Cast it back with client as typeof client & ModelExtensions. Re-applying the registry inside WithModels was 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    false

Putting 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:

  • $extends drops $on from the client it hands back, and the layer is built on $extends, so $on is 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.Result forwards three arguments to GetResult, whose fourth is the global omit, so a base method typed through the public helper cannot see it. The base extensions reach GetResult directly 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.