This document details the internal design decisions, protocol configurations, runtime interception workflows, and performance characteristics under load. It serves as the primary technical reference for maintainers and potential contributors.
Prisma generators communicate with the parent Prisma CLI using a standard JSON-RPC 2.0 handshake over standard streams (stdio).
The Prisma CLI's GeneratorProcess spawns child generator processes with a specific standard stream redirection:
stdin(fd 0) is configured aspipe. The CLI writes JSON-RPC request payloads here.stdout(fd 1) is configured asinherit. This connects the generator's standard out directly to the active parent shell/terminal for printing purposes. The CLI does not capture or parse messages sent here.stderr(fd 2) is configured aspipe. The CLI reads streams from here and parses them as lines. If a line is valid JSON, it processes it as a JSON-RPC response.
To conform to this design:
- All RPC responses (
getManifestandgenerate) are written directly toprocess.stderrrather thanprocess.stdout. - The
readlineengine reading inputs fromprocess.stdinomits theoutputstream property (setting it toundefined) to prevent any echo-backs or carriage returns from leaking intoprocess.stdout. - Once the
generatephase completes, the generator notifies the CLI onprocess.stderrand terminates cleanly utilizingprocess.exit(0).
┌──────────────────────┐
│ Prisma CLI │
└───────┬──────▲───────┘
│ │
JSON-RPC Request │ │ JSON-RPC Response
on stdin (fd 0) │ │ on stderr (fd 2)
▼ │
┌──────────────────────┐
│ Generator │
│ (src/generator.ts) │
└──────────────────────┘
When database models have bidirectional relations (e.g. User has many Posts, and Post belongs to User), compiling their schema definitions eagerly can trigger a Temporal Dead Zone (TDZ) crash at runtime.
Initially, schemas were defined using eager class-level static properties:
export class User extends Base {
static schema = {
posts: { type: Array, values: { type: Post } } // ReferenceError: Post is not defined yet!
}
}If User.ts imports Post.ts and Post.ts imports User.ts (circular relation), one of the classes will be accessed before its class declaration is fully executed, causing a ReferenceError during module loading.
We resolve circular dependency issues—without resorting to lazy class loaders or dynamic resolution libraries—by compiling schemas with a lazy static getter:
export class User extends Base {
private static _schema: SchemaDefinition | null = null;
static get schema(): SchemaDefinition {
if (!User._schema) {
User._schema = {
// ... properties defined here ...
} as const satisfies SchemaDefinition;
}
return User._schema;
}
}This defers schema compilation until runtime validation or hydration actually occurs (on demand), ensuring all generated model classes are fully declared and available in the module scope.
The modelcoreExtension intercepts queries to validate incoming input and hydrate outbound output.
Incoming Args ──────────────────────────┐
│ │
├── Writes: (create, update, upsert) │ (Selective Field Validation)
│ ├── Map fields to Schema │
│ └── Bypass Prisma Operators │
│ (e.g., connect, set) │
│ │
▼ ▼
Prisma DB Query ────────────────────────┘
│
▼
Outbound Result ────────────────────────┐
│ │
└── Reads: (findMany, findUnique) │ (Graceful Hydration Boundary)
├── If hydrate: true │
└── try {wrapSingle(res)} │
└── catch -> fallback raw │
Prisma updates permit partial datasets (e.g., updating only a user's name or age). We loop through the update payload's keys:
- If the key exists in our model's schema, we evaluate it with ModelCore's
runValidatemethod. - If the value contains Prisma update operators (such as atomic increments
{ increment: 1 }or relations{ connect: { id: 2 } }), validation is bypassed. The extension passes database-level operations through to Prisma safely.
Output hydration (instantiating ModelCore classes from database records) intercepts the query result stream in wrapSingle.
To prevent schema mismatches, type conflicts, or database/schema drift from crashing query execution loops on read paths:
- The instantiator is wrapped in a robust
try/catchblock. - If hydration fails, it prints a console warning and falls back to returning the raw database payload directly.
To evaluate raw query speed we benchmarked 10,000 query records with relation inclusions on a Sqlite database. The following latency numbers were recorded:
| Phase / Operation | Raw Latency (ms) | Target Coverage | Engine Action |
|---|---|---|---|
| Prisma Generation | 24 ms - 26 ms | Full datamodel generation | Code emission & index creation |
Plain findMany |
733.6 ms | 10k items (un-hydrated) | Database execution |
Hydrated findMany |
684.1 ms | 10k items (into Base subclasses) |
Database execution + Model class hydration |
| Incremental Updates | < 2.0 ms | 1 model | Memory filter check in validation loop |
At 10,000 records, the overhead is well within the noise threshold of raw DB operations (overhead factor of ~0.9x to 1.1x). This means hydrating database results into fully validated ModelCore classes introduces virtually zero noticeable query delay.
A benchmark suite is located in the /playground directory. To verify changes locally:
- Build the generator code:
npm run build - Navigate to the sandbox directory:
cd playground - Generate client files and run:
npx prisma generate && npx tsx benchmark.ts