diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..7f171c4ae5 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "docs", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter", "docs", "exec", "next", "dev", "--port", "3105"], + "port": 3105 + } + ] +} diff --git a/apps/docs/content/docs/orm/next/extensions/meta.json b/apps/docs/content/docs/orm/next/extensions/meta.json new file mode 100644 index 0000000000..e0703517b7 --- /dev/null +++ b/apps/docs/content/docs/orm/next/extensions/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Extensions", + "pages": ["using-extensions"] +} diff --git a/apps/docs/content/docs/orm/next/extensions/using-extensions.mdx b/apps/docs/content/docs/orm/next/extensions/using-extensions.mdx new file mode 100644 index 0000000000..906e0ee094 --- /dev/null +++ b/apps/docs/content/docs/orm/next/extensions/using-extensions.mdx @@ -0,0 +1,123 @@ +--- +title: Using extensions +description: Extensions add database capabilities like vector search, geospatial data, and full-text search to a Prisma Next project. +url: /orm/next/extensions/using-extensions +metaTitle: Using extensions in Prisma Next +metaDescription: Add an extension to a Prisma Next project, from install to first query, and browse the catalog of available extensions such as pgvector, PostGIS, and ParadeDB. +badge: early-access +--- + +An extension is a package that teaches Prisma Next a database capability it does not have out of the box: new column types, query operations, and index types, along with the migrations that install the underlying database feature. Vector search, geospatial data, full-text search, typed JSON, and provider-specific integrations all arrive this way. + +Use an extension when you want the database to do something powerful and still keep the Prisma Next experience: typed schema declarations, generated TypeScript, migration support, and query helpers that feel native to your app. + +Adding an extension takes one install and two registrations. The steps below use pgvector, the vector search extension, as the example. + +## 1. Install the package + +```npm title="Terminal" +npm install @prisma-next/extension-pgvector +``` + +## 2. Register it in the config + +This side is used when Prisma Next emits your contract and plans migrations: + +```ts title="prisma-next.config.ts" +import pgvector from '@prisma-next/extension-pgvector/control'; +import { defineConfig } from '@prisma-next/postgres/config'; + +export default defineConfig({ + contract: './src/prisma/contract.prisma', + extensions: [pgvector], + db: { + connection: process.env['DATABASE_URL']!, + }, +}); +``` + +## 3. Register it on the client + +This side is used when your app runs queries: it adds the extension's query operations and value types: + +```ts title="src/prisma/db.ts" +import pgvector from '@prisma-next/extension-pgvector/runtime'; +import postgres from '@prisma-next/postgres/runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + extensions: [pgvector], +}); +``` + +## 4. Use the new type in your schema + +The extension's types are now available in your contract. Declare a vector column with an explicit dimension: + +```prisma title="src/prisma/contract.prisma" +types { + Embedding1536 = pgvector.Vector(1536) +} + +model Post { + id String @id @default(uuid()) + title String + embedding Embedding1536? +} +``` + +## 5. Apply and query + +Run `prisma-next db init` (or `prisma-next db update` on an existing database). The extension ships its own migration, so this step runs `CREATE EXTENSION IF NOT EXISTS vector` for you. Then query with the operations the extension adds: + +```ts title="src/prisma/similarity-search.ts" +const plan = db.sql.public.post + .select('id', 'title') + .select('distance', (f, fns) => fns.cosineDistance(f.embedding, queryVector)) + .orderBy((f, fns) => fns.cosineDistance(f.embedding, queryVector), { direction: 'asc' }) + .limit(10) + .build(); + +const similar = await db.runtime().execute(plan); +``` + +## How the pieces fit + +One package, two registrations, one database: + + + +## Capabilities + +Each extension names what it adds under a key like `pgvector.cosine`. You never write these keys by hand. Registering the extension in the config records them in your contract; registering it in `db.ts` provides them at runtime. + +The point of this bookkeeping is failing early: + +- If your contract needs an extension that `db.ts` does not register, creating the client fails immediately. A query never runs against half an extension. +- If the database itself cannot install the extension, `db init` or `db update` reports it before your app takes traffic. + +## Available extensions + +Want an extension that does not exist yet? Build it: the catalog is first-party today and built for community authors, extension packs are versioned npm packages with a documented layout, and the [call for extension authors](https://www.prisma.io/blog/prisma-next-call-for-extension-authors) explains how to write and publish one. + +| Extension | Adds | Package | +| --- | --- | --- | +| [pgvector](https://github.com/prisma/prisma-next/tree/main/packages/3-extensions/pgvector#readme) | Vector columns and similarity search | `@prisma-next/extension-pgvector` | +| [PostGIS](https://github.com/prisma/prisma-next/tree/main/packages/3-extensions/postgis#readme) | Geometry columns and geo queries | `@prisma-next/extension-postgis` | +| [ParadeDB](https://github.com/prisma/prisma-next/tree/main/packages/3-extensions/paradedb#readme) | BM25 full-text search indexes | `@prisma-next/extension-paradedb` | +| [Supabase](https://github.com/prisma/prisma-next/tree/main/packages/3-extensions/supabase#readme) | Supabase auth and storage tables, role-bound clients | `@prisma-next/extension-supabase` | +| [arktype-json](https://github.com/prisma/prisma-next/tree/main/packages/3-extensions/arktype-json#readme) | JSON columns validated by an arktype schema | `@prisma-next/extension-arktype-json` | + +All target PostgreSQL. ParadeDB and Supabase are experimental (ParadeDB covers the `key_field` index option only so far); the rest are Early Access. Extension names link to each package's README on GitHub. + +For a working project per extension, see the runnable examples: [pgvector](https://github.com/prisma/prisma-next/tree/main/examples/prisma-next-demo), [PostGIS](https://github.com/prisma/prisma-next/tree/main/examples/prisma-next-postgis-demo), [ParadeDB](https://github.com/prisma/prisma-next/tree/main/examples/paradedb-demo), and [Supabase](https://github.com/prisma/prisma-next/tree/main/examples/supabase). + +## See also + +- [Advanced queries](/orm/next/fundamentals/advanced-queries): the SQL query builder, where extension operations like `cosineDistance` appear +- [How middleware works](/orm/next/middleware/how-middleware-works) for wrapping queries rather than adding database capabilities +- [Quickstart with PostgreSQL](/next/quickstart/postgresql) to set up a project to add extensions to +- [Prisma Next overview](/orm/next) for the contract-first model extensions plug into diff --git a/apps/docs/content/docs/orm/next/meta.json b/apps/docs/content/docs/orm/next/meta.json index 6c04e84a6f..09abc9d398 100644 --- a/apps/docs/content/docs/orm/next/meta.json +++ b/apps/docs/content/docs/orm/next/meta.json @@ -6,6 +6,12 @@ "---Introduction---", "index", "---Fundamentals---", - "...fundamentals" + "...fundamentals", + + "---Middleware---", + "...middleware", + + "---Extensions---", + "...extensions" ] } diff --git a/apps/docs/content/docs/orm/next/middleware/authoring-custom-middleware.mdx b/apps/docs/content/docs/orm/next/middleware/authoring-custom-middleware.mdx new file mode 100644 index 0000000000..1242da2c0b --- /dev/null +++ b/apps/docs/content/docs/orm/next/middleware/authoring-custom-middleware.mdx @@ -0,0 +1,231 @@ +--- +title: Authoring custom middleware +description: Build, register, and run your own Prisma Next middleware, step by step, starting with a query logger. +url: /orm/next/middleware/authoring-custom-middleware +metaTitle: Authoring custom middleware in Prisma Next +metaDescription: Write a custom Prisma Next middleware step by step. Build a query logger, register it, run it, and learn the full hook surface. +badge: early-access +--- + +## Introduction + +In this guide, you build your own middleware from scratch: a query logger that prints every query with its row count and latency. You create one file, register it in one place, run a query, and see the output. + +A custom middleware is a plain object with a `name` and the hooks you implement. There is no base class and no registration API beyond the `middleware` array you already know from [How middleware works](/orm/next/middleware/how-middleware-works). + +This entire guide was run end to end on a fresh `create-prisma` project against a live Prisma Postgres database; the outputs shown are from that run. + +## Prerequisites + +- A Prisma Next project with a working `src/prisma/db.ts`. The [PostgreSQL quickstart](/next/quickstart/postgresql) sets one up in a few minutes: + +```bash +npx create-prisma@next --provider postgres +cd my-app +npm run db:init +``` + +## 1. Create the middleware + +Create a new file next to your database setup. The middleware implements one hook, `afterExecute`, which runs once after every query with the outcome: + +```ts title="src/prisma/query-logger.ts" +import type { SqlMiddleware } from "@prisma-next/sql-runtime"; + +export function queryLogger(): SqlMiddleware { + return { + name: "query-logger", + familyId: "sql", + + async afterExecute(plan, result) { + console.log( + `[query-logger] ${result.rowCount} rows in ${Math.round(result.latencyMs)}ms · ${plan.sql}`, + ); + }, + }; +} +``` + +Typing the object as `SqlMiddleware` gives you inferred parameter types on every hook, so you rarely import the plan or result types yourself. `familyId: "sql"` says this middleware is for SQL databases; [step 5](#5-know-which-family-to-declare) explains when to set it. + +## 2. Register it + +Add the middleware to the `middleware` array in your client setup. This is the complete file after the change: + +```ts title="src/prisma/db.ts" +import postgres from '@prisma-next/postgres/runtime'; +import { queryLogger } from './query-logger'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + middleware: [queryLogger()], +}); +``` + +That is the whole wiring. Every query on this client now passes through the logger, whether it comes from the ORM API or the SQL query builder. + +## 3. Run a query and see the output + +Put a small script in `src/index.ts` that writes and reads: + +```ts title="src/index.ts" +import { db } from "./prisma/db"; + +await db.orm.public.User.create({ + email: `mia+${Date.now()}@prisma.io`, + name: "Mia", +}); + +const users = await db.orm.public.User.select("id", "email").take(5).all(); +console.log(`fetched ${users.length} users`); + +await db.close(); +``` + +Run it: + +```bash +npm run dev +``` + +The logger reports both queries, with the SQL the runtime actually executed: + +```text no-copy +[query-logger] 1 rows in 18ms · INSERT INTO "public"."user" ("email", "name", "updatedAt") VALUES ($1, $2, $3) RETURNING "user"."createdAt", "user"."email", "user"."id", "user"."name", "user"."updatedAt", "user"."username" +[query-logger] 1 rows in 17ms · SELECT "user"."id" AS "id", "user"."email" AS "email" FROM "public"."user" LIMIT 5 +fetched 1 users +``` + +If you see the query result but no `[query-logger]` lines, the query ran on a client that does not have the middleware registered; check that your script imports `db` from the file you edited in step 2. + +## 4. Add an option + +Real middleware usually takes options. Add a threshold so the logger only reports slow queries: + +```ts title="src/prisma/query-logger.ts" +import type { SqlMiddleware } from "@prisma-next/sql-runtime"; + +export interface QueryLoggerOptions { + /** Only log queries slower than this many milliseconds. Default: 0, log everything. */ + readonly thresholdMs?: number; +} + +export function queryLogger(options?: QueryLoggerOptions): SqlMiddleware { + const thresholdMs = options?.thresholdMs ?? 0; + + return { + name: "query-logger", + familyId: "sql", + + async afterExecute(plan, result) { + if (result.latencyMs < thresholdMs) return; + console.log( + `[query-logger] ${result.rowCount} rows in ${Math.round(result.latencyMs)}ms · ${plan.sql}`, + ); + }, + }; +} +``` + +Register it with a threshold: + +```ts title="src/prisma/db.ts" +middleware: [queryLogger({ thresholdMs: 250 })], +``` + +Run the script again and the logger goes quiet, because both queries finish in under 250ms. Drop the threshold back to see them again. + +One placement rule worth knowing: hooks run in registration order, and a middleware that throws stops the ones after it. Register your logger before middleware that can throw, such as `budgets`, so the queries you most want to see still get logged. + +## 5. Know which family to declare + +Prisma Next groups databases into families: SQL (PostgreSQL) and document (MongoDB). Set `familyId: 'sql'` when your middleware touches anything SQL-shaped, like `plan.sql` in the logger above. The runtime then rejects it on MongoDB at startup with `RUNTIME.MIDDLEWARE_FAMILY_MISMATCH`, instead of failing at query time. + +Leave `familyId` out only when the middleware sticks to family-neutral surfaces (`intercept`, `onRow`, `afterExecute` on the shared result shape). Such a middleware runs on PostgreSQL and MongoDB alike; the [cache](/orm/next/middleware/built-in-cache) works this way. + +You have a working, configurable middleware. The rest of this page is the surface you reach for as your middleware grows. + +## The hooks + +`afterExecute` is one of five hooks. Implement any subset: + +| Hook | Runs | Use it to | +| --- | --- | --- | +| `beforeCompile(draft, ctx)` | Before the query AST becomes SQL | Rewrite the query, for example add a tenant filter | +| `beforeExecute(plan, ctx, params?)` | Before the driver, with `plan.sql` rendered | Validate, throw to block, or adjust parameter values | +| `intercept(plan, ctx)` | Right before the driver | Return `{ rows }` to answer the query yourself | +| `onRow(row, plan, ctx)` | Once per streamed row | Count or sample rows; throw to abort | +| `afterExecute(plan, result, ctx)` | After the query finishes | Log timing, row count, and outcome | + +[How middleware works](/orm/next/middleware/how-middleware-works#the-five-hooks) walks the order with an animation. Two hooks deserve a closer look here, because they change the query rather than observe it. + +### Rewrite queries with beforeCompile + +`beforeCompile` sees the typed AST before it becomes SQL. Return a new draft to rewrite the query. This middleware adds a predicate to every `SELECT` on the `user` table: + +```ts title="src/prisma/scope-user-selects.ts" +import type { SqlMiddleware } from '@prisma-next/sql-runtime'; +import { AndExpr, type BinaryExpr } from '@prisma-next/sql-relational-core/ast'; + +export function scopeUserSelects(predicate: BinaryExpr): SqlMiddleware { + return { + name: 'scope-user-selects', + familyId: 'sql', + + async beforeCompile(draft) { + if (draft.ast.kind !== 'select') return undefined; + if (draft.ast.from?.kind !== 'table-source') return undefined; + if (draft.ast.from.name !== 'user') return undefined; + const where = draft.ast.where ? AndExpr.of([draft.ast.where, predicate]) : predicate; + return { ...draft, ast: draft.ast.withWhere(where) }; + }, + }; +} +``` + +Return `undefined` to pass the query through unchanged. Because each middleware's returned draft feeds the next, rewrites compose in registration order. + +### Answer queries with intercept + +Return rows from `intercept` and the driver never runs. Caches, test fixtures, rate limiters, and circuit breakers all fit this hook. Return raw row objects; the runtime decodes them normally, and `afterExecute` reports `source: 'middleware'` so the short-circuit stays visible to your logging. The [cache middleware](/orm/next/middleware/built-in-cache) is the reference implementation. + +## The context object + +Every hook receives a context (`ctx`) as its last argument: + +| Field | What it gives you | +| --- | --- | +| `ctx.planExecutionId` | The same unique ID in every hook of one query, for correlating observations | +| `ctx.now()` | The runtime's clock; prefer it over `Date.now()` so tests can control time | +| `ctx.scope` | `'runtime'`, `'connection'`, or `'transaction'`; skip work in scopes you should not touch | +| `ctx.mode` | `'strict'` or `'permissive'`; throw in strict environments, warn elsewhere | +| `ctx.contentHash(plan)` | A stable digest of statement plus parameters, for cache keys | +| `ctx.contract` | The runtime's contract, when a hook needs schema information | +| `ctx.log` | Structured log sinks (`info`, `warn`, `error`) wired to the runtime's logger | + +One honest note on `ctx.log`: the `postgres(...)` client does not expose a way to attach a log sink yet, so events sent to `ctx.log` are not printed anywhere by default. For output you can see today, log directly to your own logger, as the query logger above does with `console.log`. + +## Common gotchas + +:::warning + +- Throwing from `afterExecute` on a successful query fails the call even though the database work already happened. Reserve it for cases where failing loudly is the point, as the [budgets](/orm/next/middleware/built-in-budgets) latency check does. +- `afterExecute` also runs when the driver fails, with `result.completed` set to `false`. Errors you throw on that failure path are swallowed so they cannot mask the driver error. + +::: + +## Prompt your coding agent + +Projects scaffolded with `create-prisma` install Prisma Next skills for your coding agent. Prompts that map to this guide: + +- "Write a Prisma Next middleware that logs every query slower than 250ms, and register it before budgets." +- "Add a beforeCompile middleware that scopes every SELECT on the user table to the current tenant." +- "Write an intercept middleware that returns fixture rows for the products table in tests." + +## Next steps + +- [How middleware works](/orm/next/middleware/how-middleware-works): the lifecycle your hooks plug into +- [Built-in: budgets](/orm/next/middleware/built-in-budgets), [Built-in: lints](/orm/next/middleware/built-in-lints), and [Built-in: cache](/orm/next/middleware/built-in-cache) as production examples of the hook surface diff --git a/apps/docs/content/docs/orm/next/middleware/built-in-budgets.mdx b/apps/docs/content/docs/orm/next/middleware/built-in-budgets.mdx new file mode 100644 index 0000000000..5466e3b711 --- /dev/null +++ b/apps/docs/content/docs/orm/next/middleware/built-in-budgets.mdx @@ -0,0 +1,89 @@ +--- +title: 'Built-in: budgets' +description: The budgets middleware caps row counts and makes over-latency queries visible. +url: /orm/next/middleware/built-in-budgets +metaTitle: 'Prisma Next built-in middleware: budgets' +metaDescription: Wire and configure the budgets middleware in Prisma Next to cap row counts, report query latency, and understand the errors it raises. +badge: early-access +--- + +The `budgets` middleware puts a cost ceiling on every query: it rejects or warns on queries that would read more rows than you allow, counts rows while they stream, and reports queries that run longer than your latency budget. + +Register it on the runtime with the row and latency limits you want to enforce: + +```ts title="src/prisma/db.ts" +import postgres from '@prisma-next/postgres/runtime'; +import { budgets } from '@prisma-next/sql-runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + middleware: [ + budgets({ + maxRows: 10_000, + defaultTableRows: 10_000, + tableRows: { user: 10_000, post: 10_000 }, + maxLatencyMs: 1_000, + }), + ], +}); +``` + +`budgets` is SQL-family middleware (it declares `familyId: 'sql'`), so it registers on PostgreSQL runtimes. It ships with `@prisma-next/sql-runtime`, which comes in through `@prisma-next/postgres`; you do not install anything extra. + +## Options + +All options are optional. + +| Option | Type | Default | What it controls | +| --- | --- | --- | --- | +| `maxRows` | `number` | `10_000` | The most rows a single query may produce, checked against both the pre-execution estimate and the observed row stream | +| `defaultTableRows` | `number` | `10_000` | Assumed row count for tables not listed in `tableRows`, used by the estimator | +| `tableRows` | `Record` | `{}` | Per-table row-count assumptions for the estimator, keyed by table name | +| `maxLatencyMs` | `number` | `1_000` | Latency budget for one query, checked after execution | +| `severities.rowCount` | `'warn' \| 'error'` | `'error'` | Whether pre-execution row-budget violations block the query or log a warning in permissive mode. Strict mode always blocks | +| `severities.latency` | `'warn' \| 'error'` | (none) | Accepted by the options type but not read by the middleware in v0.14; latency behavior follows the runtime mode (see below) | + +The runtime defaults to strict mode. In strict mode, over-budget latency throws `BUDGET.TIME_EXCEEDED`; in permissive mode, the same event is logged as a warning. The latency check happens after execution, so it is a visibility tool rather than a cancellation mechanism. Setting `severities.latency` does not change this in v0.14: the key exists on the options type, but the middleware does not consult it yet. + +## How it enforces the budget + +The middleware checks at three points in the query lifecycle: + +1. **Before execution**, it inspects the query plan's AST. A `SELECT` with no `LIMIT` (and no aggregation that collapses the result) is treated as unbounded, and a bounded query whose estimated rows exceed `maxRows` is over budget. The estimate comes from `tableRows` and `defaultTableRows`, so tune those to your data. Violations raise `BUDGET.ROWS_EXCEEDED` before the driver runs, or warn in permissive mode when `severities.rowCount` is set to `'warn'`. +2. **While rows stream**, it counts what the database actually returns. If the observed count passes `maxRows`, it throws `BUDGET.ROWS_EXCEEDED` and aborts the stream, which protects you when the estimate was wrong. +3. **After execution**, it compares the measured `latencyMs` against `maxLatencyMs` and reports `BUDGET.TIME_EXCEEDED` when the query ran too long. This check runs after the work is done, so it cannot save the slow query itself; it exists to make slow queries loud instead of invisible. + +## What you see when a budget trips + +A blocked query rejects with a runtime error carrying the budget code and details: + +```text title="Row budget error" +BUDGET.ROWS_EXCEEDED: Unbounded SELECT query exceeds budget + { source: 'ast', maxRows: 10000 } +``` + +The `source` field tells you whether the violation came from the pre-execution estimate (`'ast'`) or the observed row stream (`'observed'`). In strict mode, latency violations carry the measured and allowed values: + +```text title="Latency budget error" +BUDGET.TIME_EXCEEDED: Query latency exceeds budget + { latencyMs: 2481, maxLatencyMs: 1000 } +``` + +In permissive mode, the same information is emitted through the runtime logger as a warning. To fix an unbounded-select violation, add a limit to the query ([`take(...)` on the ORM API](/orm/next/fundamentals/reading-data#sort-and-paginate), `.limit(...)` on the SQL query builder) or raise the budget deliberately. + +## Common gotchas + +:::warning + +The pre-execution row check estimates from `tableRows` and `defaultTableRows`, not from live table statistics. If your real tables are much larger than the configured assumptions, a query can pass the estimate and still hit the observed-row limit mid-stream. Keep `tableRows` roughly honest for the tables you query most. + +::: + +## See also + +- [How middleware works](/orm/next/middleware/how-middleware-works) +- [Built-in: lints](/orm/next/middleware/built-in-lints) for structural query checks that complement budgets +- [Authoring custom middleware](/orm/next/middleware/authoring-custom-middleware) diff --git a/apps/docs/content/docs/orm/next/middleware/built-in-cache.mdx b/apps/docs/content/docs/orm/next/middleware/built-in-cache.mdx new file mode 100644 index 0000000000..bcdcdebf63 --- /dev/null +++ b/apps/docs/content/docs/orm/next/middleware/built-in-cache.mdx @@ -0,0 +1,104 @@ +--- +title: 'Built-in: cache' +description: The cache middleware serves repeated reads from an in-memory store, opted in per query with a cache annotation. +url: /orm/next/middleware/built-in-cache +metaTitle: 'Prisma Next built-in middleware: cache' +metaDescription: Wire the Prisma Next cache middleware, opt queries in with cacheAnnotation and a TTL, and understand cache keys, scopes, and hits. +badge: early-access +--- + +The cache middleware serves repeated reads from an in-memory store instead of the database. Caching is strictly opt-in per query: only queries annotated with a TTL are cached, everything else passes through untouched. + +Use it for hot, repeatable reads such as dashboards, lookup tables, feature flags, navigation data, or expensive search results where a short stale window is acceptable. + +Install the package and register the middleware: + +```npm title="Terminal" +npm install @prisma-next/middleware-cache +``` + +```ts title="src/prisma/db.ts" +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; +import postgres from '@prisma-next/postgres/runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + middleware: [createCacheMiddleware({ maxEntries: 1_000 })], +}); +``` + +When you combine it with other middleware that implement `intercept`, register the cache first when cached rows should win. `beforeExecute` hooks still run before a cache hit is served, and `afterExecute` still fires afterward with `result.source` set to `'middleware'`. + +The cache is family-agnostic: unlike `budgets` and `lints`, it works on MongoDB runtimes as well as PostgreSQL. + +## Opt a query in + +Annotate a read with `cacheAnnotation` and a `ttl` in milliseconds. On the SQL query builder, chain `.annotate(...)` onto the plan: + +```ts title="src/prisma/get-users-cached.ts" +import { cacheAnnotation } from '@prisma-next/middleware-cache'; + +const plan = db.sql.public.user + .select('id', 'email') + .annotate(cacheAnnotation({ ttl: 60_000 })) + .limit(10) + .build(); + +const users = await db.runtime().execute(plan); +``` + +On the ORM API, pass the annotation through the query's meta callback: + +```ts title="src/prisma/get-user-cached.ts" +import { cacheAnnotation } from '@prisma-next/middleware-cache'; +import { db } from './db'; + +const user = await db.orm.public.User.first({ id }, (meta) => + meta.annotate(cacheAnnotation({ ttl: 60_000 })), +); +``` + +The first call runs against the database and stores the raw rows. A second call with the same query and parameters inside the TTL window is served from the store, and the driver is not invoked. + +The annotation payload has three fields: + +| Field | Type | What it does | +| --- | --- | --- | +| `ttl` | `number` | Time-to-live in milliseconds. Without a `ttl`, the annotation is inert and the query is not cached | +| `skip` | `boolean` | When `true`, bypasses the cache for this call even though a `ttl` is set. Useful as a force-refresh knob | +| `key` | `string` | Overrides the computed cache key with your own string, stored as-is | + +`cacheAnnotation` is declared read-only (`applicableTo: ['read']`). Passing it to a write such as `create`, `update`, or `delete` is both a type error and a runtime error, so a mutation cannot be cached by accident. + +## How keys and hits work + +By default the cache key is a content hash of the executed query: the post-lowering statement plus its bound parameters, combined with the contract's storage hash. Two lookups with different parameter values land in different slots; the same lookup hits. Because schema migrations rotate the storage hash, entries cached before a migration cannot serve queries against the new schema. + +Cache hits are still visible to the rest of the middleware chain: `beforeExecute` hooks have already run, the driver and `onRow` are skipped, and `afterExecute` fires on every middleware with `result.source` set to `'middleware'` instead of `'driver'`, so logging and metrics keep working for cached reads. + +The cache only acts at runtime scope. Queries executed inside a transaction or on an explicitly checked-out connection always go to the database, because read-after-write consistency is the caller's expectation there. + +## Options + +| Option | Type | Default | What it controls | +| --- | --- | --- | --- | +| `maxEntries` | `number` | `1000` | Maximum entries in the default in-memory store; least-recently-used entries are evicted first. Only consulted when `store` is omitted | +| `store` | `CacheStore` | in-memory LRU | Bring your own store implementation, for example one backed by Redis | +| `clock` | `() => number` | `Date.now` | Time source used to stamp `storedAt` on committed entries. TTL expiry lives in the store implementation | + +## Common gotchas + +:::warning + +The default store is in-memory and per-process. Two instances of your app do not share it, and a deploy clears it. That makes it a good fit for hot, tolerant-of-staleness reads, and a poor fit as a source of truth. For shared caching, implement `store` against your infrastructure. + +::: + +## See also + +- [How middleware works](/orm/next/middleware/how-middleware-works) +- [Authoring custom middleware](/orm/next/middleware/authoring-custom-middleware), including how `intercept` short-circuiting works +- [Built-in: budgets](/orm/next/middleware/built-in-budgets) diff --git a/apps/docs/content/docs/orm/next/middleware/built-in-lints.mdx b/apps/docs/content/docs/orm/next/middleware/built-in-lints.mdx new file mode 100644 index 0000000000..26e631ea31 --- /dev/null +++ b/apps/docs/content/docs/orm/next/middleware/built-in-lints.mdx @@ -0,0 +1,94 @@ +--- +title: 'Built-in: lints' +description: The lints middleware inspects each query's structure before it runs and blocks or warns on risky shapes. +url: /orm/next/middleware/built-in-lints +metaTitle: 'Prisma Next built-in middleware: lints' +metaDescription: Wire and configure the lints middleware in Prisma Next to block DELETE and UPDATE without WHERE and warn on unbounded or SELECT * queries. +badge: early-access +--- + +The `lints` middleware inspects the structure of every query before it executes and blocks or warns on shapes that are usually mistakes, like a `DELETE` with no `WHERE` clause. + +Register it on the runtime; the defaults are useful without configuration: + +```ts title="src/prisma/db.ts" +import postgres from '@prisma-next/postgres/runtime'; +import { lints } from '@prisma-next/sql-runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + middleware: [lints()], +}); +``` + +`lints` is SQL-family middleware (it declares `familyId: 'sql'`), so it registers on PostgreSQL runtimes. It ships with `@prisma-next/sql-runtime`, which comes in through `@prisma-next/postgres`. + +## The rules + +Lints run in `beforeExecute`, against the query plan's AST. Each rule has a severity: `error` throws and blocks the query before it reaches the database, `warn` logs through the runtime log and lets the query run. + +| Rule | Code | Default severity | Fires when | +| --- | --- | --- | --- | +| DELETE without WHERE | `LINT.DELETE_WITHOUT_WHERE` | `error` | A `DELETE` has no `WHERE` clause | +| UPDATE without WHERE | `LINT.UPDATE_WITHOUT_WHERE` | `error` | An `UPDATE` has no `WHERE` clause | +| No LIMIT | `LINT.NO_LIMIT` | `warn` | A `SELECT` has no `LIMIT` clause | +| SELECT star | `LINT.SELECT_STAR` | `warn` | A query selects all columns instead of naming them | + +A warning is emitted as a structured event on the runtime's log: + +```text title="Lint warning" +warn { + code: 'LINT.NO_LIMIT', + message: 'Unbounded SELECT may return large result sets', + details: { table: 'user' } +} +``` + +:::note + +The `postgres(...)` client does not expose a way to attach a log sink yet, so `warn` findings are not printed anywhere by default; only `error` severities are observable, because they block the query. Treat `warn` rules as forward-looking until the client accepts a logger. + +::: + +## Configure severities + +Raise or lower any rule per project through `severities`. For example, treat unbounded selects as hard failures in a service where every list endpoint must paginate: + +```ts title="src/prisma/db.ts" +lints({ + severities: { + noLimit: 'error', + selectStar: 'warn', + }, +}); +``` + +The severity keys are `deleteWithoutWhere`, `updateWithoutWhere`, `noLimit`, and `selectStar`, plus `readOnlyMutation` for the raw-SQL guardrail described below. The options type also accepts `unindexedPredicate`, but no rule emits that finding in v0.14. + +## Raw SQL + +Queries built with the ORM client or the SQL query builder carry a typed AST, and the rules above inspect it structurally. Raw SQL does not have that structure, so `lints` falls back to text heuristics for raw plans: it flags `select *`, a missing `LIMIT`, and a mutation smuggled through a raw query marked read-only (`LINT.READ_ONLY_MUTATION`). The raw heuristics carry their own defaults: `select *` escalates to `error` for raw plans (stricter than the AST rule's `warn`), a missing `LIMIT` stays a warning, and a read-only-intent mutation is an `error`. The `severities` keys above override raw findings too. Control the fallback with `fallbackWhenAstMissing`: + +```ts title="src/prisma/db.ts" +lints({ fallbackWhenAstMissing: 'raw' }); // default: heuristic checks on the SQL text +lints({ fallbackWhenAstMissing: 'skip' }); // skip linting for plans without an AST +``` + +Heuristics can misread SQL that a structural check would classify correctly, so treat raw-SQL lint findings as a prompt to look at the query rather than a verdict. + +## Common gotchas + +:::warning + +`LINT.DELETE_WITHOUT_WHERE` and `LINT.UPDATE_WITHOUT_WHERE` default to `error`, so a deliberate full-table mutation (for example a backfill) will be blocked. For a one-off script, either add a tautological but explicit `WHERE` clause or register `lints` with that rule set to `warn` in that script's runtime setup. Lowering the severity globally gives up the protection everywhere. + +::: + +## See also + +- [How middleware works](/orm/next/middleware/how-middleware-works) +- [Built-in: budgets](/orm/next/middleware/built-in-budgets) for row-count and latency ceilings +- [Authoring custom middleware](/orm/next/middleware/authoring-custom-middleware) diff --git a/apps/docs/content/docs/orm/next/middleware/how-middleware-works.mdx b/apps/docs/content/docs/orm/next/middleware/how-middleware-works.mdx new file mode 100644 index 0000000000..6f1e5e9831 --- /dev/null +++ b/apps/docs/content/docs/orm/next/middleware/how-middleware-works.mdx @@ -0,0 +1,121 @@ +--- +title: How middleware works +description: Middleware runs your code before and after every query, so one policy can cover your whole app. +url: /orm/next/middleware/how-middleware-works +metaTitle: How middleware works in Prisma Next +metaDescription: What Prisma Next middleware is, the hooks it exposes, how to register it on the runtime, and which middleware ships built in. +badge: early-access +--- + +Middleware lets you run your own code around every query your app sends through Prisma Next. + +For example, you can log every query with its latency, block a `DELETE` that has no `WHERE` clause before it reaches the database, or serve a repeated read from memory instead of running it again. You write the policy once, register it once, and every query follows it. No call sites to update. + +A middleware is a plain object with a name and one or more hooks. Register it once, in the `middleware` option of your client setup: + +```ts title="src/prisma/db.ts" +import { createCacheMiddleware } from '@prisma-next/middleware-cache'; +import postgres from '@prisma-next/postgres/runtime'; +import { budgets, lints } from '@prisma-next/sql-runtime'; +import type { Contract } from './contract.d'; +import contractJson from './contract.json' with { type: 'json' }; + +export const db = postgres({ + contractJson, + url: process.env['DATABASE_URL']!, + middleware: [ + createCacheMiddleware({ maxEntries: 1_000 }), + lints(), + budgets({ maxRows: 10_000, maxLatencyMs: 1_000 }), + ], +}); +``` + +Every query passes through the chain, whether it comes from the ORM API or the SQL query builder, because both execute through the same runtime. On MongoDB, pass the same option to `mongo(...)` from `@prisma-next/mongo/runtime`. + +The whole model fits in one picture: your app talks to the database, and middleware sits in the middle. It can let a query pass, block it, or answer it itself: + + + +## The five hooks + +A middleware implements only the hooks it needs. There are five, and they run in a fixed order: + + + +The runtime calls each hook on every registered middleware, in the order they appear in the `middleware` array. What each hook is for: + +### beforeCompile: rewrite the query + +Runs before the query is turned into SQL, while it is still a typed AST. Return a modified draft to rewrite the query, for example to add a tenant filter to every `SELECT`. This hook exists on SQL databases only, because it works on the SQL query AST. + +### beforeExecute: validate or block + +Runs after the SQL is rendered and before anything reaches the database. The hook sees the full plan (`plan.sql`, `plan.ast`). Throw an error to block the query: + +```ts +async beforeExecute(plan) { + if (isForbidden(plan)) throw new Error("blocked by policy"); +} +``` + +This is where `lints` stops a `DELETE` without `WHERE`, and where `budgets` rejects a query that would read too many rows. This hook can also adjust parameter values before they are encoded for the driver. + +### intercept: answer without the database + +Runs last before the driver. Return `{ rows }` and the driver never runs; return nothing and the query continues. This is how the cache serves repeated reads. Validation from `beforeExecute` has already happened by this point. + +### onRow: watch rows stream + +Runs once per row as the database streams results. Count rows, sample them, or throw to abort the stream. `budgets` uses this hook to stop a query that returns more rows than its estimate promised. + +### afterExecute: observe the finished query + +Runs once at the end, success or failure. It receives the outcome: `result.rowCount`, `result.latencyMs`, `result.completed`, and `result.source`, which is `'driver'` for a normal query and `'middleware'` when an `intercept` answered. Timing, logging, and latency budgets live here. + +Two ordering rules matter in practice: + +- Registration order is execution order at every hook. If one middleware rewrites the query in `beforeCompile`, the next middleware sees the rewritten version. +- If several middleware implement `intercept`, the first one that returns rows wins. + +## What ships built in + +Three middleware ship with Prisma Next today: + +| Middleware | Import | What it does | +| --- | --- | --- | +| [`budgets`](/orm/next/middleware/built-in-budgets) | `@prisma-next/sql-runtime` | Blocks queries that would read too many rows, and reports queries that overrun a latency budget | +| [`lints`](/orm/next/middleware/built-in-lints) | `@prisma-next/sql-runtime` | Blocks or warns on risky query shapes, such as `DELETE` without `WHERE` | +| [`cache`](/orm/next/middleware/built-in-cache) | `@prisma-next/middleware-cache` | Serves repeated reads from an in-memory store, opted in per query | + +To write your own, follow the [authoring guide](/orm/next/middleware/authoring-custom-middleware): it builds a working query logger step by step. + +## Which databases a middleware runs on + +Prisma Next groups databases into families: the SQL family (PostgreSQL and other SQL databases) and the document family (MongoDB). A middleware that inspects SQL text or the SQL query AST only makes sense on SQL databases, so it declares which family it belongs to with `familyId: 'sql'`. + +- `budgets` and `lints` declare `familyId: 'sql'`. They register on PostgreSQL and are rejected on MongoDB. +- The cache declares no `familyId`. It works against the family-neutral parts of a query, so it runs on both PostgreSQL and MongoDB. + +The runtime checks this when you construct the client. A middleware that does not match the runtime fails at startup with `RUNTIME.MIDDLEWARE_FAMILY_MISMATCH`, not at query time in production. + +## When a middleware or the driver throws + +A middleware that throws from `beforeCompile`, `beforeExecute`, `intercept`, or `onRow` fails the query: the error goes to the caller and the database work stops. + +When the driver itself fails, `afterExecute` still runs on every middleware with `result.completed` set to `false`, so your logging sees failed queries too. Errors thrown from `afterExecute` while handling a failure are swallowed, so they cannot mask the original error. + +## Prompt your coding agent + +Projects scaffolded with `create-prisma` install Prisma Next skills for your coding agent. Prompts that map to this page: + +- "Register the lints and budgets middleware on our Prisma Next client with a 10k row budget." +- "Add the cache middleware and opt the dashboard queries in with a 60 second TTL." +- "Write a middleware that logs every query slower than 250ms." + +## See also + +- [Authoring custom middleware](/orm/next/middleware/authoring-custom-middleware): build and test a query logger step by step +- [Writing data](/orm/next/fundamentals/writing-data): the mutations that lints and budgets guard +- [Built-in: budgets](/orm/next/middleware/built-in-budgets), [Built-in: lints](/orm/next/middleware/built-in-lints), [Built-in: cache](/orm/next/middleware/built-in-cache) +- [Using extensions](/orm/next/extensions/using-extensions) for adding database capabilities rather than wrapping queries diff --git a/apps/docs/content/docs/orm/next/middleware/meta.json b/apps/docs/content/docs/orm/next/middleware/meta.json new file mode 100644 index 0000000000..713c6a9861 --- /dev/null +++ b/apps/docs/content/docs/orm/next/middleware/meta.json @@ -0,0 +1,10 @@ +{ + "title": "Middleware", + "pages": [ + "how-middleware-works", + "built-in-budgets", + "built-in-lints", + "built-in-cache", + "authoring-custom-middleware" + ] +} diff --git a/apps/docs/cspell.json b/apps/docs/cspell.json index af3453afcd..cce0b6b964 100644 --- a/apps/docs/cspell.json +++ b/apps/docs/cspell.json @@ -235,6 +235,7 @@ "Pacman", "pageinspect", "pegasusheavy", + "paradedb", "permitio", "pgbouncer", "pgcat", diff --git a/apps/docs/src/components/concept-animation/flow-presets.ts b/apps/docs/src/components/concept-animation/flow-presets.ts index 28a2b91e3d..7e24d4d54b 100644 --- a/apps/docs/src/components/concept-animation/flow-presets.ts +++ b/apps/docs/src/components/concept-animation/flow-presets.ts @@ -519,12 +519,237 @@ const githubConnection: FlowScene = { ], }; +// One query's round trip: app → middleware chain → driver, and back out. +// The app and database boxes span both lanes so the return edge (and the +// cache's short-circuit) can run through the clear band under the chain. +const middlewarePipeline: FlowScene = { + label: "How a query moves through middleware", + width: 660, + height: 220, + nodes: [ + { + id: "app", + label: "Your app", + variant: "neutral", + x: 24, + y: 48, + w: 150, + h: 124, + }, + { + id: "mw", + label: "Middleware", + sub: "cache · lints · budgets", + variant: "source", + x: 240, + y: 78, + w: 190, + h: 64, + }, + { + id: "db", + label: "Database", + variant: "project", + x: 496, + y: 48, + w: 140, + h: 124, + }, + ], + edges: [ + { id: "fwd1", from: "app", fromSide: "r", to: "mw", toSide: "l" }, + { id: "fwd2", from: "mw", fromSide: "r", to: "db", toSide: "l" }, + { + id: "ret", + from: "db", + fromSide: "l", + to: "app", + toSide: "r", + fromDy: 44, + toDy: 44, + label: "results", + }, + { + id: "hit", + from: "mw", + fromSide: "b", + to: "app", + toSide: "r", + toDy: 44, + dashed: true, + label: "cached rows", + }, + ], + steps: [ + { + title: "1. In the middle", + caption: "Every query passes through your middleware on its way to the database.", + nodes: ["app", "mw", "db"], + edges: ["fwd1", "fwd2"], + emphasize: ["mw"], + }, + { + title: "2. Block", + caption: + "Middleware can stop a query before it reaches the database. This is how lints blocks a DELETE without WHERE.", + nodes: ["app", "mw", "db"], + edges: ["fwd1"], + emphasize: ["mw"], + }, + { + title: "3. Answer", + caption: + "Middleware can answer a query itself. On a cache hit, the database is skipped entirely.", + nodes: ["app", "mw", "db"], + edges: ["fwd1", "hit"], + emphasize: ["mw"], + }, + { + title: "4. Observe", + caption: + "Results flow back through the middleware, which sees every row and the final timing.", + nodes: ["app", "mw", "db"], + edges: ["fwd1", "fwd2", "ret"], + emphasize: ["app"], + }, + ], +}; -// --------------------------------------------------------------------------- -// Relationship scenes for the Prisma Next Fundamentals docs. -// Row colors double as a field legend: production = primary key, -// override = foreign key, preview = regular field. -// --------------------------------------------------------------------------- +// Five hooks in execution order; the driver runs between intercept and onRow. +const middlewareLifecycle: FlowScene = { + label: "The five hooks, in the order they run", + width: 700, + height: 150, + nodes: [ + { id: "bc", label: "beforeCompile", sub: "rewrite", variant: "source", x: 16, y: 40, w: 128, h: 64 }, + { id: "be", label: "beforeExecute", sub: "guard", variant: "scope", x: 156, y: 40, w: 128, h: 64 }, + { id: "ic", label: "intercept", sub: "answer early", variant: "production", x: 296, y: 40, w: 122, h: 64 }, + { id: "or", label: "onRow", sub: "each row", variant: "vars", x: 470, y: 40, w: 100, h: 64 }, + { id: "ae", label: "afterExecute", sub: "observe", variant: "branch", x: 582, y: 40, w: 112, h: 64 }, + ], + edges: [ + { id: "e1", from: "bc", fromSide: "r", to: "be", toSide: "l" }, + { id: "e2", from: "be", fromSide: "r", to: "ic", toSide: "l" }, + { id: "e3", from: "ic", fromSide: "r", to: "or", toSide: "l", label: "driver" }, + { id: "e4", from: "or", fromSide: "r", to: "ae", toSide: "l" }, + ], + steps: [ + { + title: "1. Rewrite", + caption: "beforeCompile can change the query while it is still a typed AST.", + nodes: ["bc", "be", "ic", "or", "ae"], + edges: ["e1", "e2", "e3", "e4"], + emphasize: ["bc"], + }, + { + title: "2. Guard", + caption: "beforeExecute sees the final SQL. Throw here to block the query.", + nodes: ["bc", "be", "ic", "or", "ae"], + edges: ["e1", "e2", "e3", "e4"], + emphasize: ["be"], + }, + { + title: "3. Answer early", + caption: "intercept can return rows itself; if it does, the driver never runs.", + nodes: ["bc", "be", "ic", "or", "ae"], + edges: ["e1", "e2", "e3", "e4"], + emphasize: ["ic"], + }, + { + title: "4. Watch and close", + caption: + "The driver runs, onRow fires per streamed row, and afterExecute closes with row count and latency.", + nodes: ["bc", "be", "ic", "or", "ae"], + edges: ["e1", "e2", "e3", "e4"], + emphasize: ["or", "ae"], + }, + ], +}; + +// One extension package, registered on two planes, converging on the database. +const extensionPlanes: FlowScene = { + label: "One extension package, two registrations, one database", + width: 680, + height: 250, + nodes: [ + { + id: "pkg", + label: "pgvector", + sub: "one npm package", + variant: "neutral", + x: 16, + y: 93, + w: 170, + h: 64, + }, + { + id: "cfg", + label: "prisma-next.config.ts", + sub: "migrations", + variant: "source", + x: 260, + y: 24, + w: 200, + h: 64, + }, + { + id: "rt", + label: "db.ts", + sub: "queries", + variant: "scope", + x: 260, + y: 162, + w: 200, + h: 64, + }, + { + id: "pg", + label: "PostgreSQL", + variant: "project", + x: 524, + y: 93, + w: 140, + h: 64, + }, + ], + edges: [ + { id: "p-cfg", from: "pkg", fromSide: "r", to: "cfg", toSide: "l" }, + { id: "p-rt", from: "pkg", fromSide: "r", to: "rt", toSide: "l" }, + { id: "cfg-pg", from: "cfg", fromSide: "r", to: "pg", toSide: "l", label: "db init" }, + { id: "rt-pg", from: "rt", fromSide: "r", to: "pg", toSide: "l", label: "queries" }, + ], + steps: [ + { + title: "1. Install", + caption: "One package brings everything the extension needs.", + nodes: ["pkg"], + edges: [], + emphasize: ["pkg"], + }, + { + title: "2. Config side", + caption: + "Registered in the config, the extension adds its schema types and its migration (CREATE EXTENSION).", + nodes: ["pkg", "cfg"], + edges: ["p-cfg"], + emphasize: ["cfg"], + }, + { + title: "3. Client side", + caption: "Registered in db.ts, it adds the query operations your code calls.", + nodes: ["pkg", "cfg", "rt"], + edges: ["p-cfg", "p-rt"], + emphasize: ["rt"], + }, + { + title: "4. The database", + caption: "db init installs the feature; your queries then use it.", + nodes: ["pkg", "cfg", "rt", "pg"], + edges: ["p-cfg", "p-rt", "cfg-pg", "rt-pg"], + emphasize: ["pg"], + }, + ], +}; const RELATION_LEGEND: { origin: RowOrigin; label: string }[] = [ { origin: "production", label: "primary key" }, @@ -815,6 +1040,9 @@ export const FLOW_SCENES = { "relation-one-to-one": relationOneToOne, "relation-one-to-many": relationOneToMany, "relation-many-to-many": relationManyToMany, + "middleware-pipeline": middlewarePipeline, + "middleware-lifecycle": middlewareLifecycle, + "extension-planes": extensionPlanes, } satisfies Record; export type FlowName = keyof typeof FLOW_SCENES; diff --git a/apps/docs/src/components/concept-animation/presets.ts b/apps/docs/src/components/concept-animation/presets.ts index 2588ab620c..47c21f9dc4 100644 --- a/apps/docs/src/components/concept-animation/presets.ts +++ b/apps/docs/src/components/concept-animation/presets.ts @@ -153,6 +153,87 @@ export const CONCEPT_PRESETS = { }, ], }, + "middleware-pipeline": { + label: "How a query moves through the middleware chain", + steps: [ + { + title: "1. One chain", + code: + "your app (ORM client · SQL query builder · raw)\n" + + " │ execute(plan)\n" + + " ▼\n" + + "[[cache]] → [[lints]] → [[budgets]] → driver → database", + caption: + "Every query, whichever surface issued it, runs through the same middleware array on its way to the driver, in registration order: first in the array, first at every hook.", + }, + { + title: "2. Before the driver", + code: + "your app\n" + + " │ [[beforeCompile]] · [[beforeExecute]]\n" + + " ▼\n" + + "cache → [[lints]] → [[budgets]] → driver → database", + caption: + "Before anything reaches the database, beforeCompile can rewrite the query and beforeExecute can validate it, mutate parameters, or throw to block it. This is where lints and budgets do their pre-flight checks.", + }, + { + title: "3. Cache answers early", + code: + "your app ◄───────────── [[intercept: cached rows]]\n" + + " │ │\n" + + " ▼ │\n" + + "[[cache]] ──────────────┘ lints · budgets · driver skipped", + caption: + "A middleware can answer a query itself: on a hit, the cache returns rows from intercept and the driver never runs. afterExecute still fires with source set to middleware, so observability keeps working.", + }, + { + title: "4. Rows stream back", + code: + "your app ◄── [[onRow]] (per row) ◄── database rows\n" + + " [[afterExecute]] (rowCount · latencyMs)", + caption: + "On the way back, onRow fires once per row as the driver streams, and afterExecute closes the call with the observed row count and latency.", + }, + ], + }, + "extension-planes": { + label: "How one extension package plugs into a project", + steps: [ + { + title: "1. One package", + code: "[[@prisma-next/extension-pgvector]]\n ├─ /control (build time)\n └─ /runtime (query time)", + caption: + "An extension ships as one npm package with two entry points: a control export used at build time and a runtime export used when your app runs.", + }, + { + title: "2. Control plane", + code: + "@prisma-next/extension-pgvector\n" + + " └─ /control → [[prisma-next.config.ts]]\n" + + " schema types · [[baseline migration]]", + caption: + "prisma-next.config.ts registers the control export. Contract emission picks up schema types like Vector(n), and the pack ships the baseline migration that installs the database feature.", + }, + { + title: "3. Runtime plane", + code: + "@prisma-next/extension-pgvector\n" + + " ├─ /control → prisma-next.config.ts\n" + + " └─ /runtime → [[db.ts]]\n" + + " codecs · [[query operations]]", + caption: + "db.ts registers the runtime export on the client, which gains the pack's codecs and query operations like cosineDistance. A missing required pack fails at client construction.", + }, + { + title: "4. Meet at the database", + code: + "prisma-next.config.ts → [[db init]] → CREATE EXTENSION vector\n" + + "db.ts → [[queries]] → cosineDistance(…)", + caption: + "db init applies the pack's baseline migration, and your queries run against a database that provably has the feature the contract relies on.", + }, + ], + }, } satisfies Record; export type ConceptName = keyof typeof CONCEPT_PRESETS;