prisma-generator-express 1.66.0 → 1.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -3432
- package/dist/client/encodeQueryParams.js +3 -1
- package/dist/client/encodeQueryParams.js.map +1 -1
- package/dist/copy/misc.d.ts +1 -0
- package/dist/copy/misc.js +14 -0
- package/dist/copy/misc.js.map +1 -1
- package/dist/copy/operationDefinitions.d.ts +4 -1
- package/dist/copy/operationDefinitions.js +133 -9
- package/dist/copy/operationDefinitions.js.map +1 -1
- package/dist/generators/generateFastifyHandler.d.ts +2 -2
- package/dist/generators/generateFastifyHandler.js +68 -12
- package/dist/generators/generateFastifyHandler.js.map +1 -1
- package/dist/generators/generateHonoHandler.d.ts +2 -2
- package/dist/generators/generateHonoHandler.js +53 -6
- package/dist/generators/generateHonoHandler.js.map +1 -1
- package/dist/generators/generateHonoOpenApi.d.ts +13 -0
- package/dist/generators/generateHonoOpenApi.js +70 -0
- package/dist/generators/generateHonoOpenApi.js.map +1 -0
- package/dist/generators/generateImportPrismaStatement.d.ts +1 -0
- package/dist/generators/generateImportPrismaStatement.js +6 -0
- package/dist/generators/generateImportPrismaStatement.js.map +1 -1
- package/dist/generators/generateMcpApp.d.ts +10 -0
- package/dist/generators/generateMcpApp.js +113 -0
- package/dist/generators/generateMcpApp.js.map +1 -0
- package/dist/generators/generateModelMcp.d.ts +9 -0
- package/dist/generators/generateModelMcp.js +145 -0
- package/dist/generators/generateModelMcp.js.map +1 -0
- package/dist/generators/generateModelMetadata.js +29 -15
- package/dist/generators/generateModelMetadata.js.map +1 -1
- package/dist/generators/generateOperationCore.d.ts +3 -3
- package/dist/generators/generateOperationCore.js +51 -16
- package/dist/generators/generateOperationCore.js.map +1 -1
- package/dist/generators/generateRelationMeta.js +2 -2
- package/dist/generators/generateRelationMeta.js.map +1 -1
- package/dist/generators/generateRouteConfigType.d.ts +2 -2
- package/dist/generators/generateRouteConfigType.js +29 -11
- package/dist/generators/generateRouteConfigType.js.map +1 -1
- package/dist/generators/generateRouter.d.ts +7 -4
- package/dist/generators/generateRouter.js +164 -65
- package/dist/generators/generateRouter.js.map +1 -1
- package/dist/generators/generateRouterFastify.d.ts +7 -4
- package/dist/generators/generateRouterFastify.js +106 -86
- package/dist/generators/generateRouterFastify.js.map +1 -1
- package/dist/generators/generateRouterHono.d.ts +7 -4
- package/dist/generators/generateRouterHono.js +59 -414
- package/dist/generators/generateRouterHono.js.map +1 -1
- package/dist/generators/generateRouterPartsHono.d.ts +14 -0
- package/dist/generators/generateRouterPartsHono.js +180 -0
- package/dist/generators/generateRouterPartsHono.js.map +1 -0
- package/dist/generators/generateUnifiedDocs.d.ts +1 -1
- package/dist/generators/generateUnifiedDocs.js +15 -8
- package/dist/generators/generateUnifiedDocs.js.map +1 -1
- package/dist/generators/generateUnifiedHandler.d.ts +2 -2
- package/dist/generators/generateUnifiedHandler.js +42 -18
- package/dist/generators/generateUnifiedHandler.js.map +1 -1
- package/dist/guardOptions.js +12 -7
- package/dist/guardOptions.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +123 -4
- package/dist/index.js.map +1 -1
- package/dist/utils/copyFiles.d.ts +1 -1
- package/dist/utils/copyFiles.js +42 -7
- package/dist/utils/copyFiles.js.map +1 -1
- package/dist/utils/importExt.d.ts +1 -1
- package/dist/utils/mcpGate.d.ts +10 -0
- package/dist/utils/mcpGate.js +118 -0
- package/dist/utils/mcpGate.js.map +1 -0
- package/dist/utils/pathCasing.d.ts +4 -0
- package/dist/utils/pathCasing.js +40 -0
- package/dist/utils/pathCasing.js.map +1 -0
- package/dist/utils/resolveImportStyle.js +13 -5
- package/dist/utils/resolveImportStyle.js.map +1 -1
- package/dist/utils/writeFileSafely.js +6 -0
- package/dist/utils/writeFileSafely.js.map +1 -1
- package/package.json +22 -5
- package/src/bin.ts +1 -1
- package/src/client/encodeQueryParams.ts +39 -35
- package/src/constants.ts +1 -1
- package/src/copy/autoIncludePlanner.ts +93 -27
- package/src/copy/autoIncludePlannerGuarded.ts +171 -48
- package/src/copy/autoIncludeRuntimeGuarded.ts +25 -7
- package/src/copy/buildModelOpenApi.ts +229 -176
- package/src/copy/concurrency.ts +10 -8
- package/src/copy/docsRenderer.ts +14 -15
- package/src/copy/errorMapper.ts +68 -19
- package/src/copy/guardHelpers.ts +30 -9
- package/src/copy/materializedCount.ts +17 -5
- package/src/copy/mcpRuntime.ts +1017 -0
- package/src/copy/misc.ts +88 -68
- package/src/copy/ndjson.ts +72 -0
- package/src/copy/operationDefinitions.ts +177 -25
- package/src/copy/operationPipeline.ts +267 -0
- package/src/copy/operationRuntime.ts +124 -26
- package/src/copy/operationSchemas.ts +2773 -0
- package/src/copy/pagination.ts +35 -13
- package/src/copy/parseQueryParams.ts +47 -48
- package/src/copy/projectionDefaults.ts +6 -4
- package/src/copy/routeConfig.express.ts +16 -4
- package/src/copy/routeConfig.fastify.ts +16 -2
- package/src/copy/routeConfig.hono.ts +46 -14
- package/src/copy/routeConfig.ts +175 -40
- package/src/copy/routerParts.ts +524 -0
- package/src/copy/scalarTypes.ts +7 -2
- package/src/copy/sse.ts +72 -19
- package/src/generators/generateFastifyHandler.ts +77 -20
- package/src/generators/generateHonoHandler.ts +62 -14
- package/src/generators/generateHonoOpenApi.ts +93 -0
- package/src/generators/generateImportPrismaStatement.ts +93 -81
- package/src/generators/generateMcpApp.ts +133 -0
- package/src/generators/generateModelMcp.ts +208 -0
- package/src/generators/generateModelMetadata.ts +40 -22
- package/src/generators/generateOperationCore.ts +81 -33
- package/src/generators/generateRelationMeta.ts +23 -11
- package/src/generators/generateRouteConfigType.ts +39 -13
- package/src/generators/generateRouter.ts +171 -68
- package/src/generators/generateRouterFastify.ts +117 -89
- package/src/generators/generateRouterHono.ts +84 -464
- package/src/generators/generateRouterPartsHono.ts +220 -0
- package/src/generators/generateUnifiedDocs.ts +29 -8
- package/src/generators/generateUnifiedHandler.ts +47 -24
- package/src/generators/generateUnifiedScalarUI.ts +1 -1
- package/src/guardOptions.ts +105 -92
- package/src/index.ts +161 -6
- package/src/utils/copyFiles.ts +235 -168
- package/src/utils/importExt.ts +2 -2
- package/src/utils/mcpGate.ts +157 -0
- package/src/utils/pathCasing.ts +42 -0
- package/src/utils/resolveImportStyle.ts +37 -15
- package/src/utils/strings.ts +2 -2
- package/src/utils/writeFileSafely.ts +12 -2
package/README.md
CHANGED
|
@@ -1,274 +1,67 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="docs/assets/favicon.svg" width="88" height="88" alt="Prisma Generator Express logo">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/multipliedtwice/prisma-generator-express/master/docs/assets/favicon.svg" width="88" height="88" alt="Prisma Generator Express logo">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Prisma Generator Express</h1>
|
|
6
6
|
|
|
7
|
-
<p align="center"><strong>Ship Prisma APIs faster by generating Express, Fastify, or Hono CRUD routes, OpenAPI, pagination, hooks, and
|
|
7
|
+
<p align="center"><strong>Ship Prisma APIs faster by generating Express, Fastify, or Hono CRUD routes, OpenAPI, pagination, hooks, guard enforcement, and guarded MCP tools directly from your schema.</strong></p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/prisma-generator-express"><img src="https://badge.fury.io/js/prisma-generator-express.svg" alt="npm version"></a>
|
|
11
11
|
<a href="https://www.npmjs.com/package/prisma-generator-express"><img src="https://img.shields.io/npm/dt/prisma-generator-express.svg" alt="npm downloads"></a>
|
|
12
12
|
<a href="https://codecov.io/gh/multipliedtwice/prisma-generator-express"><img src="https://img.shields.io/codecov/c/github/multipliedtwice/prisma-generator-express/master.svg" alt="Coverage"></a>
|
|
13
|
-
<a href="LICENSE"><img src="https://img.shields.io/npm/l/prisma-generator-express.svg" alt="License"></a>
|
|
13
|
+
<a href="https://github.com/multipliedtwice/prisma-generator-express/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/prisma-generator-express.svg" alt="License"></a>
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<a href="https://multipliedtwice.github.io/prisma-generator-express/">Field Guide</a> ·
|
|
18
|
-
<a href="https://
|
|
19
|
-
<a href="
|
|
20
|
-
<a href="article-labs/http/">HTTP/PostgreSQL Evidence</a> ·
|
|
21
|
-
<a href="#documentation-endpoints">API Documentation</a>
|
|
18
|
+
<a href="https://multipliedtwice.github.io/prisma-generator-express/guide/">Full reference</a> ·
|
|
19
|
+
<a href="https://www.npmjs.com/package/prisma-generator-express">npm</a>
|
|
22
20
|
</p>
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- Handler functions for all Prisma operations (findMany, create, update, delete, etc.)
|
|
27
|
-
- Schema-level `findManyPaginated` execution mode selection (`Promise.all` or interactive transaction)
|
|
28
|
-
- Per-route and per-endpoint pagination config, including optional materialized-view count sources
|
|
29
|
-
- Router generator with operation-wide and per-variant before/after hooks
|
|
30
|
-
- POST read endpoints for all read operations (for complex queries exceeding URL length limits)
|
|
31
|
-
- Express-only progressive read streaming over Server-Sent Events (SSE), using manual stages or auto-include splitting for supported relation reads, including unguarded deep `findMany` / `findManyPaginated` auto-include paths and guarded single-record auto-include paths
|
|
32
|
-
- Express-only standalone materialized view router for read-only access to registered PostgreSQL materialized views
|
|
33
|
-
- OpenAPI 3.1 spec (JSON and YAML endpoints registered automatically per router)
|
|
34
|
-
- Documentation helpers for contract view and Scalar UI (require manual mounting)
|
|
35
|
-
- Client-side query parameter encoder
|
|
36
|
-
- Guard/variant shape enforcement via prisma-guard integration
|
|
37
|
-
|
|
38
|
-
Supports **Express**, **Fastify**, and **Hono** targets via the `target` configuration option.
|
|
39
|
-
|
|
40
|
-
## Table of contents
|
|
41
|
-
|
|
42
|
-
- [Compatibility](#compatibility)
|
|
43
|
-
- [Installation](#installation)
|
|
44
|
-
- [Setup](#setup)
|
|
45
|
-
- [Write strategy](#write-strategy)
|
|
46
|
-
- [findManyPaginated execution mode](#findmanypaginated-execution-mode)
|
|
47
|
-
- [Path casing in generated endpoints](#path-casing-in-generated-endpoints)
|
|
48
|
-
- [Usage (Express)](#usage-express)
|
|
49
|
-
- [Usage (Fastify)](#usage-fastify)
|
|
50
|
-
- [Usage (Hono)](#usage-hono)
|
|
51
|
-
- [Selective routes with middleware](#selective-routes-with-middleware)
|
|
52
|
-
- [Guard shapes (prisma-guard integration)](#guard-shapes-prisma-guard-integration)
|
|
53
|
-
- [Request body format](#request-body-format)
|
|
54
|
-
- [Query encoding (client side)](#query-encoding-client-side)
|
|
55
|
-
- [POST read endpoints](#post-read-endpoints)
|
|
56
|
-
- [Materialized views router (Express)](#materialized-views-router-express)
|
|
57
|
-
- [Progressive Endpoint Composition (Express SSE)](#progressive-endpoint-composition-express-sse)
|
|
58
|
-
- [updateEach (Express, Fastify, Hono, internal batch)](#updateeach-express-fastify-hono-internal-batch)
|
|
59
|
-
- [Response shaping: select, include, omit](#response-shaping-select-include-omit)
|
|
60
|
-
- [BigInt and Decimal handling](#bigint-and-decimal-handling)
|
|
61
|
-
- [Pagination](#pagination)
|
|
62
|
-
- [Error handling](#error-handling)
|
|
63
|
-
- [Security](#security)
|
|
64
|
-
- [Documentation endpoints](#documentation-endpoints)
|
|
65
|
-
- [prisma-sql integration](#prisma-sql-integration)
|
|
66
|
-
- [Query parameter parsing](#query-parameter-parsing)
|
|
67
|
-
- [Router schema](#router-schema)
|
|
68
|
-
- [Skipping models](#skipping-models)
|
|
69
|
-
- [Configuration](#configuration)
|
|
70
|
-
- [Environment variables](#environment-variables)
|
|
71
|
-
- [License](#license)
|
|
72
|
-
|
|
73
|
-
## Compatibility
|
|
74
|
-
|
|
75
|
-
### Prisma version
|
|
76
|
-
|
|
77
|
-
Minimum supported Prisma version: **6.0.0**
|
|
78
|
-
|
|
79
|
-
Some operations require newer versions:
|
|
80
|
-
|
|
81
|
-
| Operation | Minimum Prisma version | Notes |
|
|
82
|
-
| --------------------- | ---------------------- | ------------------------------------ |
|
|
83
|
-
| `omit` parameter | 6.2.0 | Returns 400 on versions 6.0.x–6.1.x |
|
|
84
|
-
| `createManyAndReturn` | 5.14.0 | PostgreSQL, CockroachDB, SQLite only |
|
|
85
|
-
| `updateManyAndReturn` | 6.2.0 | PostgreSQL, CockroachDB, SQLite only |
|
|
86
|
-
|
|
87
|
-
### Framework support
|
|
88
|
-
|
|
89
|
-
| Framework | Target value | Generated output |
|
|
90
|
-
| --------- | ------------ | ---------------- |
|
|
91
|
-
| Express | `"express"` | `express.Router()` factory function per model |
|
|
92
|
-
| Fastify | `"fastify"` | Fastify plugin function per model |
|
|
93
|
-
| Hono | `"hono"` | `Hono` instance factory function per model |
|
|
94
|
-
|
|
95
|
-
The Hono target v1 is tested on Node.js runtimes only. See [Cloudflare Workers and edge runtimes](#cloudflare-workers-and-edge-runtimes).
|
|
96
|
-
|
|
97
|
-
Progressive Endpoint Composition over Server-Sent Events is currently supported by the Express target only. Express supports both manual staged streaming and auto-include streaming for supported relation reads. Unguarded auto-include supports single-record reads and deep `findMany` / `findManyPaginated` reads within the configured planner limits. Guarded auto-include supports selected single-record read shapes while keeping every root and relation-stage query inside prisma-guard. Fastify and Hono continue to support normal JSON read and write routes.
|
|
98
|
-
|
|
99
|
-
### Database provider support
|
|
100
|
-
|
|
101
|
-
Most operations work across all Prisma-supported providers. Exceptions:
|
|
102
|
-
|
|
103
|
-
| Feature | PostgreSQL | CockroachDB | MySQL | SQLite | SQL Server | MongoDB |
|
|
104
|
-
| --------------------- | ---------- | ----------- | ----- | ------ | ---------- | ------- |
|
|
105
|
-
| `createManyAndReturn` | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ |
|
|
106
|
-
| `updateManyAndReturn` | ✓ | ✓ | ✗ | ✓ | ✗ | ✗ |
|
|
107
|
-
| `skipDuplicates` | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
|
|
108
|
-
|
|
109
|
-
Operations not supported by your database provider return `501 Not Implemented` at runtime. The generator emits handlers for all operations regardless of provider — use selective route configuration to expose only supported operations.
|
|
110
|
-
|
|
111
|
-
## Installation
|
|
112
|
-
|
|
113
|
-
```bash
|
|
114
|
-
npm install -D prisma-generator-express
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Peer dependencies for Express:
|
|
22
|
+
## Quickstart
|
|
118
23
|
|
|
119
24
|
```bash
|
|
25
|
+
npm install -D prisma-generator-express prisma
|
|
120
26
|
npm install @prisma/client express
|
|
121
27
|
```
|
|
122
28
|
|
|
123
|
-
Peer dependencies for Fastify:
|
|
124
|
-
|
|
125
|
-
```bash
|
|
126
|
-
npm install @prisma/client fastify
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
Peer dependencies for Hono:
|
|
130
|
-
|
|
131
|
-
```bash
|
|
132
|
-
npm install @prisma/client hono
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Optional peer dependencies:
|
|
136
|
-
|
|
137
|
-
```bash
|
|
138
|
-
npm install prisma-sql # SQL optimization
|
|
139
|
-
npm install prisma-guard zod # Guard shape enforcement
|
|
140
|
-
npm install prisma-query-builder-ui # Visual query playground (Express/Fastify only — not auto-started for Hono)
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
## Setup
|
|
144
|
-
|
|
145
|
-
Add the generator to your `schema.prisma`:
|
|
146
|
-
|
|
147
29
|
```prisma
|
|
148
|
-
|
|
149
|
-
|
|
30
|
+
// schema.prisma — project root
|
|
31
|
+
datasource db {
|
|
32
|
+
provider = "sqlite"
|
|
33
|
+
url = "file:./dev.db"
|
|
150
34
|
}
|
|
151
35
|
|
|
152
|
-
generator
|
|
153
|
-
provider = "prisma-
|
|
36
|
+
generator client {
|
|
37
|
+
provider = "prisma-client-js"
|
|
154
38
|
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
To target Fastify or Hono, set the `target` config:
|
|
158
39
|
|
|
159
|
-
```prisma
|
|
160
40
|
generator express {
|
|
161
41
|
provider = "prisma-generator-express"
|
|
162
|
-
|
|
42
|
+
output = "./generated/express"
|
|
163
43
|
}
|
|
164
|
-
```
|
|
165
44
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
45
|
+
model User {
|
|
46
|
+
id Int @id @default(autoincrement())
|
|
47
|
+
email String @unique
|
|
48
|
+
name String
|
|
170
49
|
}
|
|
171
50
|
```
|
|
172
51
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
The generator detects the Prisma client generator automatically. All standard provider values are supported: `prisma-client-js`, `@prisma/client`, and `prisma-client`.
|
|
176
|
-
|
|
177
|
-
Generate:
|
|
52
|
+
> On Prisma 7 the datasource `url` moves to `prisma.config.ts` — see the
|
|
53
|
+
> [compatibility section](https://multipliedtwice.github.io/prisma-generator-express/guide/#compatibility).
|
|
178
54
|
|
|
179
55
|
```bash
|
|
180
56
|
npx prisma generate
|
|
181
57
|
```
|
|
182
58
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
`writeStrategy` is a schema-wide generator option. It controls only non-returning bulk data writes that have Prisma returning counterparts: `createMany` and `updateMany`. It does not affect `deleteMany`.
|
|
186
|
-
|
|
187
|
-
```prisma
|
|
188
|
-
generator express {
|
|
189
|
-
provider = "prisma-generator-express"
|
|
190
|
-
writeStrategy = "regular"
|
|
191
|
-
}
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
Valid values:
|
|
195
|
-
|
|
196
|
-
| Value | Behavior |
|
|
197
|
-
| ----- | -------- |
|
|
198
|
-
| `regular` | Default. `createMany` and `updateMany` call the normal Prisma methods and return `{ count }`. |
|
|
199
|
-
| `throwOnNonReturning` | Disables the generated `createMany` and `updateMany` endpoints (`POST /{modelname}/many` and `PUT /{modelname}/many`). Direct calls return `501`. Use `createManyAndReturn` and `updateManyAndReturn` endpoints instead. |
|
|
200
|
-
| `forceReturn` | `createMany` silently invokes `createManyAndReturn`, and `updateMany` silently invokes `updateManyAndReturn`. These endpoints return arrays of records instead of `{ count }` and support `select`, `include`, and `omit`. |
|
|
201
|
-
|
|
202
|
-
`forceReturn` still follows Prisma provider support. If the current provider does not support `createManyAndReturn` or `updateManyAndReturn`, the generated endpoint returns `501 Not Implemented` at runtime.
|
|
203
|
-
|
|
204
|
-
Example:
|
|
205
|
-
|
|
206
|
-
```prisma
|
|
207
|
-
generator express {
|
|
208
|
-
provider = "prisma-generator-express"
|
|
209
|
-
target = "express"
|
|
210
|
-
writeStrategy = "forceReturn"
|
|
211
|
-
}
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
## findManyPaginated execution mode
|
|
215
|
-
|
|
216
|
-
`findManyPaginatedMode` is a schema-wide generator option. It controls how generated `findManyPaginated` handlers execute the root `findMany` query and the total-count query.
|
|
217
|
-
|
|
218
|
-
```prisma
|
|
219
|
-
generator express {
|
|
220
|
-
provider = "prisma-generator-express"
|
|
221
|
-
findManyPaginatedMode = "promiseAll"
|
|
222
|
-
}
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
Valid values:
|
|
226
|
-
|
|
227
|
-
| Value | Behavior |
|
|
228
|
-
| ----- | -------- |
|
|
229
|
-
| `promiseAll` | Default. Generates `Promise.all([findMany, count])`. This is faster and works on clients without interactive transaction support, but the returned `data` and `total` are not atomic under concurrent writes. |
|
|
230
|
-
| `transaction` | Generates an interactive transaction around `findMany` and `count`. This keeps `data` and `total` consistent inside the transaction, but returns `500` if the Prisma client does not expose `$transaction`. There is no implicit fallback. |
|
|
231
|
-
|
|
232
|
-
This option affects normal JSON `findManyPaginated` responses and Express SSE auto-include `findManyPaginated` responses.
|
|
233
|
-
|
|
234
|
-
Use `transaction` when atomic page metadata matters more than latency. Use `promiseAll` when throughput and broad runtime compatibility matter more than strict consistency between `data` and `total`.
|
|
235
|
-
|
|
236
|
-
Example:
|
|
237
|
-
|
|
238
|
-
```prisma
|
|
239
|
-
generator express {
|
|
240
|
-
provider = "prisma-generator-express"
|
|
241
|
-
target = "express"
|
|
242
|
-
findManyPaginatedMode = "transaction"
|
|
243
|
-
}
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
## Path casing in generated endpoints
|
|
247
|
-
|
|
248
|
-
Model names are converted to **flat lowercase** in URL paths. There is no kebab-case or snake_case conversion — the model name is lowercased character by character.
|
|
249
|
-
|
|
250
|
-
| Model name | URL path |
|
|
251
|
-
| ----------------- | -------------------- |
|
|
252
|
-
| `User` | `/user` |
|
|
253
|
-
| `BlogPost` | `/blogpost` |
|
|
254
|
-
| `OrderItem` | `/orderitem` |
|
|
255
|
-
| `INVOICE_RECORDS` | `/invoice_records` |
|
|
256
|
-
| `apiKey` | `/apikey` |
|
|
257
|
-
|
|
258
|
-
Underscores in model names are preserved. Camel-case word boundaries are not preserved.
|
|
259
|
-
|
|
260
|
-
Throughout this README, `{modelname}` (lowercase) represents the converted path segment. For example, the path `/{modelname}/first` refers to `/user/first` for a `User` model, or `/blogpost/first` for a `BlogPost` model.
|
|
261
|
-
|
|
262
|
-
The generated directory structure preserves the original model casing — e.g. `generated/BlogPost/BlogPostRouter.ts` — but the runtime URL is `/blogpost`.
|
|
263
|
-
|
|
264
|
-
To remove the model prefix entirely, set `addModelPrefix: false` in the route config. To replace it with a custom prefix, use `customUrlPrefix`.
|
|
265
|
-
|
|
266
|
-
## Usage (Express)
|
|
59
|
+
Mount the generated router:
|
|
267
60
|
|
|
268
61
|
```ts
|
|
269
62
|
import express from 'express'
|
|
270
63
|
import { PrismaClient } from '@prisma/client'
|
|
271
|
-
import { UserRouter } from './generated/User/UserRouter'
|
|
64
|
+
import { UserRouter } from './generated/express/User/UserRouter'
|
|
272
65
|
|
|
273
66
|
const prisma = new PrismaClient()
|
|
274
67
|
const app = express()
|
|
@@ -280,3270 +73,233 @@ app.use((req, res, next) => {
|
|
|
280
73
|
next()
|
|
281
74
|
})
|
|
282
75
|
|
|
283
|
-
|
|
284
|
-
enableAll: true,
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
app.use('/', UserRouter(userConfig))
|
|
76
|
+
app.use('/', UserRouter({ enableAll: true }))
|
|
288
77
|
|
|
289
|
-
app.listen(3000
|
|
290
|
-
console.log('Server is running on http://localhost:3000')
|
|
291
|
-
})
|
|
78
|
+
app.listen(3000)
|
|
292
79
|
```
|
|
293
80
|
|
|
294
|
-
|
|
81
|
+
That is a full CRUD API with OpenAPI docs at `/user/openapi.json`.
|
|
295
82
|
|
|
296
|
-
|
|
83
|
+
> `enableAll: true` exposes every operation of the model. For anything beyond internal tools, restrict operations and add guard shapes — see [guard integration](https://multipliedtwice.github.io/prisma-generator-express/guide/#guard-shapes-prisma-guard-integration).
|
|
297
84
|
|
|
298
|
-
|
|
85
|
+
## What you get
|
|
299
86
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
87
|
+
- Handler functions for all Prisma operations (`findMany`, `create`, `update`, `delete`, aggregates and more) — [request body format](https://multipliedtwice.github.io/prisma-generator-express/guide/#request-body-format)
|
|
88
|
+
- **Express, Fastify, and Hono** targets via the `target` generator option — [Express](https://multipliedtwice.github.io/prisma-generator-express/guide/#usage-express) / [Fastify](https://multipliedtwice.github.io/prisma-generator-express/guide/#usage-fastify) / [Hono](https://multipliedtwice.github.io/prisma-generator-express/guide/#usage-hono)
|
|
89
|
+
- OpenAPI 3.1 spec (JSON + YAML) registered automatically per router — [documentation endpoints](https://multipliedtwice.github.io/prisma-generator-express/guide/#documentation-endpoints)
|
|
90
|
+
- Router generator with operation-wide and per-variant before/after hooks — [selective routes](https://multipliedtwice.github.io/prisma-generator-express/guide/#selective-routes-with-middleware)
|
|
91
|
+
- Schema-level `findManyPaginated` execution mode (`Promise.all` or interactive transaction) — [execution mode](https://multipliedtwice.github.io/prisma-generator-express/guide/#findmanypaginated-execution-mode)
|
|
92
|
+
- Per-route and per-endpoint pagination config, including materialized-view count sources — [pagination](https://multipliedtwice.github.io/prisma-generator-express/guide/#pagination)
|
|
93
|
+
- POST read endpoints for complex queries exceeding URL length limits — [POST reads](https://multipliedtwice.github.io/prisma-generator-express/guide/#post-read-endpoints)
|
|
94
|
+
- Guard/variant shape enforcement with tenant isolation via [prisma-guard](https://github.com/multipliedtwice/prisma-guard) — [guard shapes](https://multipliedtwice.github.io/prisma-generator-express/guide/#guard-shapes-prisma-guard-integration)
|
|
95
|
+
- Opt-in MCP transport (`mcp = true`): one Streamable HTTP `/mcp` endpoint in the same process, explicit per-model allowlist, verified-principal authorization, SDK v2. Read and write actions are exposed only when allowlisted; all nine guarded write actions (create, createMany, createManyAndReturn, update, updateMany, updateManyAndReturn, upsert, delete, deleteMany) are per-operation opt-in and never implied by `enableAll`. MCP represents each action as a tool — [MCP guide](https://multipliedtwice.github.io/prisma-generator-express/guide/#mcp-model-context-protocol)
|
|
96
|
+
- Express-only progressive read streaming over SSE (manual stages or auto-include splitting) — [progressive composition](https://multipliedtwice.github.io/prisma-generator-express/guide/#progressive-endpoint-composition-express-sse)
|
|
97
|
+
- Express-only read-only materialized view router — [materialized views](https://multipliedtwice.github.io/prisma-generator-express/guide/#materialized-views-router-express)
|
|
98
|
+
- Client-side query parameter encoder — [query encoding](https://multipliedtwice.github.io/prisma-generator-express/guide/#query-encoding-client-side)
|
|
99
|
+
- SQL optimization via optional [prisma-sql](https://multipliedtwice.github.io/prisma-generator-express/guide/#prisma-sql-integration) integration
|
|
304
100
|
|
|
305
|
-
|
|
306
|
-
const fastify = Fastify()
|
|
101
|
+
## Compatibility
|
|
307
102
|
|
|
308
|
-
|
|
103
|
+
| Framework | Target value | Generated output |
|
|
104
|
+
| --------- | ------------ | ---------------- |
|
|
105
|
+
| Express | `"express"` (default) | `express.Router()` factory per model |
|
|
106
|
+
| Fastify | `"fastify"` | Fastify plugin function per model |
|
|
107
|
+
| Hono | `"hono"` | `Hono` instance factory per model |
|
|
309
108
|
|
|
310
|
-
|
|
311
|
-
request.prisma = prisma
|
|
312
|
-
})
|
|
109
|
+
Minimum supported Prisma version: **6.0.0** (CI generates and strict-typechecks emitted output against the latest 6.x and 7.x lines). Some operations require newer versions or specific database providers — see the [compatibility section](https://multipliedtwice.github.io/prisma-generator-express/guide/#compatibility) for the exact matrix.
|
|
313
110
|
|
|
314
|
-
|
|
315
|
-
enableAll: true,
|
|
316
|
-
}
|
|
111
|
+
Progressive SSE streaming and the materialized views router are Express-only today.
|
|
317
112
|
|
|
318
|
-
|
|
319
|
-
await UserRoutes(instance, userConfig)
|
|
320
|
-
})
|
|
113
|
+
## Documentation
|
|
321
114
|
|
|
322
|
-
|
|
323
|
-
console.log('Server is running on http://localhost:3000')
|
|
324
|
-
})
|
|
325
|
-
```
|
|
115
|
+
The full reference lives in the [GitHub Pages guide](https://multipliedtwice.github.io/prisma-generator-express/guide/):
|
|
326
116
|
|
|
327
|
-
|
|
117
|
+
- [Guard shapes and variants](https://multipliedtwice.github.io/prisma-generator-express/guide/#guard-shapes-prisma-guard-integration) — whitelists, forced values, caller routing, tenant isolation
|
|
118
|
+
- [Configuration](https://multipliedtwice.github.io/prisma-generator-express/guide/#configuration) — every route-config option
|
|
119
|
+
- [Environment variables](https://multipliedtwice.github.io/prisma-generator-express/guide/#environment-variables)
|
|
120
|
+
- [Pagination](https://multipliedtwice.github.io/prisma-generator-express/guide/#pagination), [error handling](https://multipliedtwice.github.io/prisma-generator-express/guide/#error-handling), [security notes](https://multipliedtwice.github.io/prisma-generator-express/guide/#security)
|
|
121
|
+
- [updateEach batch route](https://multipliedtwice.github.io/prisma-generator-express/guide/#updateeach-express-fastify-hono-internal-batch)
|
|
122
|
+
- [MCP tools](https://multipliedtwice.github.io/prisma-generator-express/guide/#mcp-model-context-protocol) — enable, allowlist, auth, limits, opt-in writes, fail-closed rules
|
|
328
123
|
|
|
329
|
-
|
|
124
|
+
### MCP quickstart
|
|
330
125
|
|
|
331
|
-
|
|
126
|
+
This example is executed as a test against generated output
|
|
127
|
+
(`packages/generator/test/unit/mcp/readmeQuickstart.test.ts`) — real Postgres, real prisma-guard, real auth. It is the actual shape, not pseudocode.
|
|
332
128
|
|
|
333
|
-
|
|
334
|
-
import { Hono } from 'hono'
|
|
335
|
-
import { PrismaClient } from '@prisma/client'
|
|
336
|
-
import { UserRouter } from './generated/User/UserRouter'
|
|
129
|
+
<!-- readme-example:mcp-quickstart-schema:start -->
|
|
337
130
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
131
|
+
```prisma
|
|
132
|
+
// schema.prisma
|
|
133
|
+
datasource db {
|
|
134
|
+
provider = "postgresql"
|
|
135
|
+
url = env("DATABASE_URL")
|
|
342
136
|
}
|
|
343
137
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
app.use('*', async (c, next) => {
|
|
348
|
-
c.set('prisma', prisma)
|
|
349
|
-
await next()
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
const userConfig = {
|
|
353
|
-
enableAll: true,
|
|
138
|
+
generator client {
|
|
139
|
+
provider = "prisma-client-js"
|
|
354
140
|
}
|
|
355
141
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
The generated function signature is `UserRouter(config?: RouteConfig): Hono`. Mount with `app.route(prefix, UserRouter(config))`.
|
|
362
|
-
|
|
363
|
-
PrismaClient is injected via `c.set('prisma', prismaInstance)` in middleware that runs before the router. Declare `prisma` (and any optional connectors like `postgres` / `sqlite`) in your Hono app's `Variables` type so TypeScript can verify the injection. The same pattern applies to optional `postgres` / `sqlite` connectors for [prisma-sql integration](#prisma-sql-integration).
|
|
364
|
-
|
|
365
|
-
### Hooks (Hono)
|
|
366
|
-
|
|
367
|
-
Hono route hooks are generated pre/post handler hooks, not native Hono middleware chains. A hook continues by returning `void`. It short-circuits by returning a `Response`, and errors by throwing, including `HTTPException`.
|
|
368
|
-
|
|
369
|
-
```ts
|
|
370
|
-
import type { HonoBeforeHook } from './generated/routeConfig.target'
|
|
371
|
-
|
|
372
|
-
const auth: HonoBeforeHook = async (c) => {
|
|
373
|
-
const token = c.req.header('authorization')
|
|
374
|
-
if (!token) return c.json({ message: 'Unauthorized' }, 401)
|
|
142
|
+
generator guard {
|
|
143
|
+
provider = "prisma-guard"
|
|
144
|
+
output = "./generated/guard"
|
|
375
145
|
}
|
|
376
146
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
147
|
+
generator api {
|
|
148
|
+
provider = "prisma-generator-express"
|
|
149
|
+
output = "./generated/api"
|
|
150
|
+
target = "express"
|
|
151
|
+
mcp = true
|
|
381
152
|
}
|
|
382
|
-
```
|
|
383
153
|
|
|
384
|
-
|
|
154
|
+
model User {
|
|
155
|
+
id String @id @default(cuid())
|
|
156
|
+
email String @unique
|
|
157
|
+
name String?
|
|
158
|
+
siteId String
|
|
159
|
+
posts Post[]
|
|
385
160
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
Throwing Hono's `HTTPException` from a hook short-circuits to a JSON error response. The router's `app.onError` catches the exception, preserves the status code, and **normalizes the response body** to `{ "message": err.message }`.
|
|
389
|
-
|
|
390
|
-
```ts
|
|
391
|
-
import { HTTPException } from 'hono/http-exception'
|
|
392
|
-
import type { HonoBeforeHook } from './generated/routeConfig.target'
|
|
393
|
-
|
|
394
|
-
const auth: HonoBeforeHook = async (c) => {
|
|
395
|
-
const token = c.req.header('authorization')
|
|
396
|
-
if (!token) {
|
|
397
|
-
throw new HTTPException(401, { message: 'Unauthorized' })
|
|
398
|
-
}
|
|
161
|
+
// the tenant-safe unique selector the write actions use
|
|
162
|
+
@@unique([siteId, email])
|
|
399
163
|
}
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
Custom response bodies attached to `HTTPException` are **not preserved** — the router always returns `{ message: err.message }` with the exception's status code. If you need a custom response body, return a `Response` directly from the hook instead of throwing.
|
|
403
|
-
|
|
404
|
-
This normalization ensures all errors from generated routes share a single shape, so clients only need to handle one error format.
|
|
405
|
-
|
|
406
|
-
### Cloudflare Workers and edge runtimes
|
|
407
164
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
```ts
|
|
415
|
-
type Env = {
|
|
416
|
-
Bindings: {
|
|
417
|
-
PRISMA: any
|
|
418
|
-
}
|
|
419
|
-
Variables: {
|
|
420
|
-
prisma: any
|
|
421
|
-
}
|
|
165
|
+
model Post {
|
|
166
|
+
id String @id @default(cuid())
|
|
167
|
+
title String
|
|
168
|
+
author User @relation(fields: [authorId], references: [id])
|
|
169
|
+
authorId String
|
|
422
170
|
}
|
|
423
|
-
|
|
424
|
-
const app = new Hono<Env>()
|
|
425
|
-
|
|
426
|
-
app.use('*', async (c, next) => {
|
|
427
|
-
c.set('prisma', c.env.PRISMA)
|
|
428
|
-
await next()
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
app.route('/', UserRouter({ enableAll: true }))
|
|
432
|
-
|
|
433
|
-
export default app
|
|
434
171
|
```
|
|
435
172
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
### Query Builder
|
|
439
|
-
|
|
440
|
-
The Query Builder playground is Node-only and **not auto-started** by the Hono target. The generated `?ui=playground` route can render the playground iframe, but the Hono router never starts the Query Builder server, even when query builder config is present. Start `prisma-query-builder-ui` manually in a separate process and point the config to that server when needed.
|
|
441
|
-
|
|
442
|
-
### Query string differences
|
|
443
|
-
|
|
444
|
-
Hono's `c.req.query()` returns a flat `Record<string, string>` — duplicate query keys collapse to the last value. For example, `?take=10&take=20` becomes `{ take: '20' }`. This differs from Express, which parses `?a=1&a=2` into `{ a: ['1', '2'] }`.
|
|
445
|
-
|
|
446
|
-
The `encodeQueryParams` client utility does not emit duplicate keys, so this only matters for hand-built query strings. All complex Prisma arguments are JSON-encoded into single query values.
|
|
173
|
+
<!-- readme-example:mcp-quickstart-schema:end -->
|
|
447
174
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
| PrismaClient injection | `req.prisma = prisma` | `request.prisma = prisma` | `c.set('prisma', prisma)` |
|
|
458
|
-
| Error handling | Express error middleware | `setErrorHandler` | `app.onError` |
|
|
459
|
-
| Query Builder auto-start | Yes (Node only) | Yes (Node only) | No (manual start) |
|
|
460
|
-
|
|
461
|
-
## Selective routes with middleware
|
|
175
|
+
```bash
|
|
176
|
+
# install dependencies FIRST — prisma generate needs prisma-guard resolvable.
|
|
177
|
+
# The quickstart is verified against Prisma 6 (Prisma 7 moves the datasource
|
|
178
|
+
# URL into prisma.config.ts — see compatibility below).
|
|
179
|
+
npm install @prisma/client@6 prisma-guard@^1.33.0 zod
|
|
180
|
+
npm install -D prisma@6 tsx
|
|
181
|
+
# SDK v2 — the only MCP packages the generated output imports,
|
|
182
|
+
# plus the express adapter for requireBearerAuth below:
|
|
183
|
+
npm install @modelcontextprotocol/server @modelcontextprotocol/node @modelcontextprotocol/express
|
|
462
184
|
|
|
463
|
-
|
|
185
|
+
# one-off Postgres (or point DATABASE_URL at your existing instance):
|
|
186
|
+
docker run -d --name my-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16-alpine
|
|
464
187
|
|
|
465
|
-
|
|
466
|
-
const userConfig = {
|
|
467
|
-
findMany: {
|
|
468
|
-
before: [authMiddleware],
|
|
469
|
-
},
|
|
470
|
-
create: {
|
|
471
|
-
before: [authMiddleware, validateBody],
|
|
472
|
-
},
|
|
473
|
-
findUnique: {},
|
|
474
|
-
}
|
|
188
|
+
export DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/postgres"
|
|
475
189
|
|
|
476
|
-
|
|
190
|
+
npx prisma generate
|
|
191
|
+
npx prisma db push
|
|
477
192
|
```
|
|
478
193
|
|
|
479
|
-
|
|
194
|
+
<!-- readme-example:mcp-quickstart-server:start -->
|
|
480
195
|
|
|
481
196
|
```ts
|
|
482
|
-
|
|
197
|
+
// server.ts
|
|
198
|
+
import express from 'express'
|
|
199
|
+
import {
|
|
200
|
+
McpServer,
|
|
201
|
+
createMcpHandler,
|
|
202
|
+
type AuthInfo,
|
|
203
|
+
} from '@modelcontextprotocol/server'
|
|
204
|
+
import { requireBearerAuth } from '@modelcontextprotocol/express'
|
|
205
|
+
import { toNodeHandler } from '@modelcontextprotocol/node'
|
|
206
|
+
import { force } from 'prisma-guard'
|
|
207
|
+
import { PrismaClient } from '@prisma/client'
|
|
208
|
+
import { guard } from './generated/guard/client'
|
|
209
|
+
import {
|
|
210
|
+
userFindManyTool,
|
|
211
|
+
userCreateTool,
|
|
212
|
+
userUpdateTool,
|
|
213
|
+
} from './generated/api/User/UserMcp'
|
|
214
|
+
import type { UserRouteConfig } from './generated/api/User/UserRouter'
|
|
215
|
+
import { registerMcpToolsOnServer } from './generated/api/mcp'
|
|
216
|
+
|
|
217
|
+
// guarded client: guard(shape, caller) is what the tools execute through
|
|
218
|
+
const prisma = new PrismaClient().$extends(guard.extension())
|
|
219
|
+
|
|
220
|
+
// a real route config: guard shapes with a FORCED tenant value + bounded take
|
|
221
|
+
// (MCP schemas assume prisma-guard >= 1.33 semantics). Write actions are
|
|
222
|
+
// per-operation opt-in: nothing writes until you import its factory and
|
|
223
|
+
// place it in `tools`. `enableAll` never exposes writes. The annotation lets
|
|
224
|
+
// prisma-guard's shape types check every shape.
|
|
225
|
+
const userConfig: UserRouteConfig = {
|
|
483
226
|
findMany: {
|
|
484
|
-
|
|
227
|
+
shape: {
|
|
228
|
+
where: { siteId: { equals: force('tenant-a') } },
|
|
229
|
+
take: { max: 50 },
|
|
230
|
+
},
|
|
485
231
|
},
|
|
486
232
|
create: {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
findUnique: {},
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
fastify.register(async (instance) => {
|
|
493
|
-
await UserRoutes(instance, userConfig)
|
|
494
|
-
})
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
Fastify hooks receive `(request: FastifyRequest, reply: FastifyReply)`. If a hook sends a reply (via `reply.send()`), subsequent hooks and the handler are skipped.
|
|
498
|
-
|
|
499
|
-
### Hono
|
|
500
|
-
|
|
501
|
-
```ts
|
|
502
|
-
const userConfig = {
|
|
503
|
-
findMany: {
|
|
504
|
-
before: [async (c) => {
|
|
505
|
-
const token = c.req.header('authorization')
|
|
506
|
-
if (!token) return c.json({ message: 'Unauthorized' }, 401)
|
|
507
|
-
}],
|
|
233
|
+
// siteId is server-owned: agents cannot choose the tenant
|
|
234
|
+
shape: { data: { email: true, siteId: force('tenant-a') } },
|
|
508
235
|
},
|
|
509
|
-
|
|
510
|
-
|
|
236
|
+
update: {
|
|
237
|
+
// the compound selector carries the forced tenant: another tenant's
|
|
238
|
+
// user is a 404, never a write
|
|
239
|
+
shape: {
|
|
240
|
+
where: { siteId_email: { siteId: force('tenant-a'), email: true } },
|
|
241
|
+
data: { name: true },
|
|
242
|
+
},
|
|
511
243
|
},
|
|
512
|
-
findUnique: {},
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
app.route('/', UserRouter(userConfig))
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
Hono route hooks return `void` to continue. Return a `Response` (e.g. `c.json({...}, 403)`) or throw `HTTPException` to short-circuit — subsequent hooks and the handler will not run.
|
|
519
|
-
|
|
520
|
-
### Hook execution contract
|
|
521
|
-
|
|
522
|
-
Operation-level hooks run for every successfully routed variant. Variant hooks run only for the selected declared variant key.
|
|
523
|
-
|
|
524
|
-
```text
|
|
525
|
-
operation before-hooks
|
|
526
|
-
variant before-hooks
|
|
527
|
-
generated handler
|
|
528
|
-
variant after-hooks
|
|
529
|
-
operation after-hooks
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
A terminal response or an error stops the remaining hooks in that phase. Operation after-hooks are not cleanup or `finally` handlers and are not guaranteed to run. Caller-routing failures are surfaced after operation before-hooks and before variant before-hooks, preserving operation-wide logging and authentication timing.
|
|
533
|
-
|
|
534
|
-
Only operations listed in the config (or all when `enableAll: true`) are registered. Operations not listed produce no routes.
|
|
535
|
-
|
|
536
|
-
## Guard shapes (prisma-guard integration)
|
|
537
|
-
|
|
538
|
-
prisma-generator-express integrates with [prisma-guard](https://github.com/multipliedtwice/prisma-guard) to enforce input validation, query shape restrictions, and tenant isolation on generated routes. Configure either `shape` for the existing guard API or `variants` to co-locate named shapes with per-variant hooks. The handler passes the extracted guard shape map to `prisma.model.guard(shape, caller).method(args)` instead of calling Prisma directly.
|
|
539
|
-
|
|
540
|
-
Guard routing works identically across Express, Fastify, and Hono. The only target-specific differences are hook types and the type of the `resolveVariant` callback parameter (`Request` for Express, `FastifyRequest` for Fastify, `Context` for Hono).
|
|
541
|
-
|
|
542
|
-
### Guard setup
|
|
543
|
-
|
|
544
|
-
Install prisma-guard and add its generator to your schema:
|
|
545
|
-
|
|
546
|
-
```bash
|
|
547
|
-
npm install prisma-guard zod
|
|
548
|
-
```
|
|
549
|
-
|
|
550
|
-
```prisma
|
|
551
|
-
generator client {
|
|
552
|
-
provider = "prisma-client-js"
|
|
553
244
|
}
|
|
554
245
|
|
|
555
|
-
|
|
556
|
-
provider = "prisma-guard"
|
|
557
|
-
output = "generated/guard"
|
|
558
|
-
}
|
|
246
|
+
const MCP_TOKEN = process.env.MCP_TOKEN // your issuer's token
|
|
559
247
|
|
|
560
|
-
|
|
561
|
-
|
|
248
|
+
const buildServer = (authInfo: AuthInfo): McpServer => {
|
|
249
|
+
const server = new McpServer({ name: 'my-api', version: '1.0.0' })
|
|
250
|
+
registerMcpToolsOnServer(server, {
|
|
251
|
+
tools: [
|
|
252
|
+
userFindManyTool({ config: userConfig }),
|
|
253
|
+
userCreateTool({ config: userConfig }),
|
|
254
|
+
userUpdateTool({ config: userConfig }),
|
|
255
|
+
],
|
|
256
|
+
resolveCaller: (info) => info.clientId, // routing key only
|
|
257
|
+
authorize: ({ principal }) => {
|
|
258
|
+
if (!principal) throw new Error('unauthenticated')
|
|
259
|
+
},
|
|
260
|
+
defaultLimit: 20,
|
|
261
|
+
maxLimit: 100,
|
|
262
|
+
maxResultBytes: 262_144,
|
|
263
|
+
prisma,
|
|
264
|
+
authInfo, // verified principal — required, fail closed without it
|
|
265
|
+
})
|
|
266
|
+
return server
|
|
562
267
|
}
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
Run `npx prisma generate` to emit both the routes and the guard artifacts.
|
|
566
|
-
|
|
567
|
-
Extend PrismaClient with the guard extension and attach it to requests:
|
|
568
|
-
|
|
569
|
-
```ts
|
|
570
|
-
import express from 'express'
|
|
571
|
-
import { PrismaClient } from '@prisma/client'
|
|
572
|
-
import { guard } from './generated/guard/client'
|
|
573
|
-
import { UserRouter } from './generated/User/UserRouter'
|
|
574
|
-
|
|
575
|
-
const prisma = new PrismaClient().$extends(
|
|
576
|
-
guard.extension(() => ({
|
|
577
|
-
// scope context, caller, or any other values
|
|
578
|
-
}))
|
|
579
|
-
)
|
|
580
268
|
|
|
581
269
|
const app = express()
|
|
582
|
-
|
|
583
270
|
app.use(express.json())
|
|
584
271
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
},
|
|
272
|
+
// verified authentication is yours; without a verified token nothing is served
|
|
273
|
+
const auth = requireBearerAuth({
|
|
274
|
+
verifier: {
|
|
275
|
+
async verifyAccessToken(token) {
|
|
276
|
+
if (token !== MCP_TOKEN) throw new Error('invalid token')
|
|
277
|
+
return {
|
|
278
|
+
token,
|
|
279
|
+
clientId: 'tenant-a',
|
|
280
|
+
scopes: ['mcp'],
|
|
281
|
+
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
282
|
+
}
|
|
597
283
|
},
|
|
598
284
|
},
|
|
599
|
-
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const mcpHandler = createMcpHandler((ctx) => buildServer(ctx.authInfo as AuthInfo))
|
|
288
|
+
app.all('/mcp', auth, (req, res) => {
|
|
289
|
+
const node = toNodeHandler(mcpHandler)
|
|
290
|
+
node(req, res, req.body)
|
|
291
|
+
})
|
|
600
292
|
|
|
601
293
|
app.listen(3000)
|
|
602
294
|
```
|
|
603
295
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
If prisma-guard is not installed or the client is not extended with the guard extension, requests to guarded routes return 500 with the message: `Guard shapes require prisma-guard extension on PrismaClient. Install: npm install prisma-guard, then extend your client with guardExtension().`
|
|
607
|
-
|
|
608
|
-
### How guard integration works
|
|
609
|
-
|
|
610
|
-
Each operation accepts either the existing `shape` property or the new `variants` property:
|
|
611
|
-
|
|
612
|
-
- `shape` accepts one direct shape, one context-dependent shape function, or a named shape map.
|
|
613
|
-
- `variants` is a named map whose entries contain one shape plus optional per-variant hooks.
|
|
614
|
-
- `shape` and `variants` cannot both be defined on the same operation.
|
|
615
|
-
- Both may be absent when the operation only needs operation-wide hooks or pagination.
|
|
616
|
-
|
|
617
|
-
At router construction, the generated router validates and normalizes the descriptor. Variant descriptors are removed before anything reaches prisma-guard, dropped-guard projection logic, progressive planning, or OpenAPI extraction.
|
|
618
|
-
|
|
619
|
-
For each request, the generated router:
|
|
620
|
-
|
|
621
|
-
1. Resolves the raw caller from `config.guard.resolveVariant(request)`, then from the configured header (default `x-api-variant`), falling back to `undefined`.
|
|
622
|
-
2. Resolves the declared guard variant key using the same exact/default/parameterized precedence as prisma-guard.
|
|
623
|
-
3. Applies dropped-guard projection defaults immediately for successful routing when guard is globally dropped, preserving what operation before-hooks observe.
|
|
624
|
-
4. Runs operation before-hooks.
|
|
625
|
-
5. Converts a stored routing failure into HTTP 400 before any variant hook or generated handler runs.
|
|
626
|
-
6. Runs hooks for the matched declared key, calls the generated handler, then runs after-hooks in reverse scope order.
|
|
627
|
-
|
|
628
|
-
The normalized guard shape is stored on request context (`res.locals.guardShape`, `request.guardShape`, or `c.get('guardShape')`). The declared matched key is stored separately from the raw caller. For example, caller `customer/123` matching `customer/:id` stores `customer/:id` as the variant key.
|
|
629
|
-
|
|
630
|
-
When neither `shape` nor `variants` is configured, the generated handler calls Prisma directly with no guard enforcement.
|
|
631
|
-
|
|
632
|
-
### Default shape per operation
|
|
633
|
-
|
|
634
|
-
For one normal shape, use a direct shape object:
|
|
296
|
+
<!-- readme-example:mcp-quickstart-server:end -->
|
|
635
297
|
|
|
636
|
-
```
|
|
637
|
-
|
|
638
|
-
findMany: {
|
|
639
|
-
shape: {
|
|
640
|
-
where: { email: { contains: true }, role: { equals: true } },
|
|
641
|
-
orderBy: { createdAt: true },
|
|
642
|
-
take: { max: 100, default: 25 },
|
|
643
|
-
skip: true,
|
|
644
|
-
},
|
|
645
|
-
},
|
|
646
|
-
}
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
A context-dependent shape function is also valid:
|
|
650
|
-
|
|
651
|
-
```ts
|
|
652
|
-
const userConfig = {
|
|
653
|
-
findMany: {
|
|
654
|
-
shape: (ctx) => ({
|
|
655
|
-
where: {
|
|
656
|
-
companyId: { equals: ctx.companyId },
|
|
657
|
-
name: { contains: true },
|
|
658
|
-
},
|
|
659
|
-
take: { max: 50 },
|
|
660
|
-
}),
|
|
661
|
-
},
|
|
662
|
-
}
|
|
663
|
-
```
|
|
664
|
-
|
|
665
|
-
Use a named shape map when only shape routing differs and no per-variant route hooks are needed:
|
|
666
|
-
|
|
667
|
-
```ts
|
|
668
|
-
const userConfig = {
|
|
669
|
-
findMany: {
|
|
670
|
-
shape: {
|
|
671
|
-
admin: {
|
|
672
|
-
where: { email: { contains: true }, role: { equals: true } },
|
|
673
|
-
take: { max: 100 },
|
|
674
|
-
},
|
|
675
|
-
default: {
|
|
676
|
-
where: { name: { contains: true } },
|
|
677
|
-
take: { max: 20, default: 10 },
|
|
678
|
-
},
|
|
679
|
-
},
|
|
680
|
-
},
|
|
681
|
-
}
|
|
682
|
-
```
|
|
683
|
-
|
|
684
|
-
`default` is selected when the caller is missing, blank, or unmatched. Without `default`, the request returns 400. Use `variants` instead when hooks must differ by the matched shape.
|
|
685
|
-
|
|
686
|
-
### Shape value types in data
|
|
687
|
-
|
|
688
|
-
Each field in a `data` shape accepts one of four value types:
|
|
689
|
-
|
|
690
|
-
```ts
|
|
691
|
-
import { force } from 'prisma-guard'
|
|
692
|
-
|
|
693
|
-
const config = {
|
|
694
|
-
create: {
|
|
695
|
-
shape: {
|
|
696
|
-
default: {
|
|
697
|
-
data: {
|
|
698
|
-
email: true, // client-controlled, @zod chains apply
|
|
699
|
-
name: true, // client-controlled
|
|
700
|
-
role: 'member', // forced to 'member', client cannot override
|
|
701
|
-
isActive: force(true), // forced to boolean true (force() needed to distinguish from client-controlled)
|
|
702
|
-
bio: (base) => base.max(500), // client-controlled with inline validation override
|
|
703
|
-
},
|
|
704
|
-
},
|
|
705
|
-
},
|
|
706
|
-
},
|
|
707
|
-
}
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
- `true` — client provides the value; `@zod` schema directives from the Prisma schema apply
|
|
711
|
-
- literal value — server forces this value; client input is ignored
|
|
712
|
-
- `force(value)` — same as literal, but required when the forced value is literally `true` (since bare `true` means client-controlled)
|
|
713
|
-
- `(base) => schema` — client provides the value; the function receives the base Zod type and returns a refined schema, bypassing `@zod` chains
|
|
714
|
-
|
|
715
|
-
### Named shapes (variant-based routing)
|
|
716
|
-
|
|
717
|
-
Different API consumers often need different shapes for the same operation. Named shapes use a caller value to route to the correct shape.
|
|
718
|
-
|
|
719
|
-
```ts
|
|
720
|
-
const userConfig = {
|
|
721
|
-
findMany: {
|
|
722
|
-
shape: {
|
|
723
|
-
admin: {
|
|
724
|
-
where: { email: { contains: true }, role: { equals: true }, isActive: { equals: true } },
|
|
725
|
-
include: { posts: true, profile: true },
|
|
726
|
-
take: { max: 200 },
|
|
727
|
-
},
|
|
728
|
-
public: {
|
|
729
|
-
where: { name: { contains: true } },
|
|
730
|
-
select: { id: true, name: true },
|
|
731
|
-
take: { max: 20, default: 10 },
|
|
732
|
-
},
|
|
733
|
-
},
|
|
734
|
-
},
|
|
735
|
-
create: {
|
|
736
|
-
shape: {
|
|
737
|
-
admin: {
|
|
738
|
-
data: { email: true, name: true, role: true, isActive: true },
|
|
739
|
-
},
|
|
740
|
-
editor: {
|
|
741
|
-
data: { email: true, name: true, role: 'member' },
|
|
742
|
-
},
|
|
743
|
-
},
|
|
744
|
-
},
|
|
745
|
-
guard: {
|
|
746
|
-
variantHeader: 'x-api-variant',
|
|
747
|
-
},
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
app.use('/', UserRouter(userConfig))
|
|
751
|
-
```
|
|
752
|
-
|
|
753
|
-
The client sends the variant in the configured header:
|
|
754
|
-
|
|
755
|
-
```ts
|
|
756
|
-
// Admin frontend
|
|
757
|
-
fetch('/user', {
|
|
758
|
-
headers: { 'x-api-variant': 'admin' },
|
|
759
|
-
})
|
|
760
|
-
|
|
761
|
-
// Public frontend
|
|
762
|
-
fetch('/user', {
|
|
763
|
-
headers: { 'x-api-variant': 'public' },
|
|
764
|
-
})
|
|
765
|
-
```
|
|
766
|
-
|
|
767
|
-
If the caller is missing or doesn't match any key, the request is rejected with 400 (`CallerError`).
|
|
768
|
-
|
|
769
|
-
### Variant-specific hooks
|
|
770
|
-
|
|
771
|
-
Use `variants` to keep each named shape beside the hooks that belong to it:
|
|
772
|
-
|
|
773
|
-
```ts
|
|
774
|
-
const orderConfig = {
|
|
775
|
-
create: {
|
|
776
|
-
before: [authenticate],
|
|
777
|
-
after: [auditOrderCreation],
|
|
778
|
-
|
|
779
|
-
variants: {
|
|
780
|
-
customer: {
|
|
781
|
-
shape: customerCreateShape,
|
|
782
|
-
before: [bindCustomer],
|
|
783
|
-
after: [sendCustomerConfirmation],
|
|
784
|
-
},
|
|
785
|
-
|
|
786
|
-
seller: {
|
|
787
|
-
shape: sellerCreateShape,
|
|
788
|
-
before: [bindSeller],
|
|
789
|
-
},
|
|
790
|
-
|
|
791
|
-
default: {
|
|
792
|
-
shape: internalCreateShape,
|
|
793
|
-
},
|
|
794
|
-
},
|
|
795
|
-
},
|
|
796
|
-
|
|
797
|
-
guard: {
|
|
798
|
-
resolveVariant: (req) => req.user?.role,
|
|
799
|
-
},
|
|
800
|
-
}
|
|
801
|
-
```
|
|
802
|
-
|
|
803
|
-
The execution order for `customer` is:
|
|
804
|
-
|
|
805
|
-
```text
|
|
806
|
-
authenticate
|
|
807
|
-
bindCustomer
|
|
808
|
-
generated create handler
|
|
809
|
-
sendCustomerConfirmation
|
|
810
|
-
auditOrderCreation
|
|
811
|
-
```
|
|
812
|
-
|
|
813
|
-
Important rules:
|
|
814
|
-
|
|
815
|
-
- Operation-level `before` and `after` hooks run for every successfully resolved variant.
|
|
816
|
-
- Variant hooks run only for the selected declared key.
|
|
817
|
-
- `variants[key].shape` is one direct operation shape or one context-dependent shape function. It cannot contain another named map.
|
|
818
|
-
- `shape` and `variants` are mutually exclusive on one operation, but both may be absent.
|
|
819
|
-
- `default` is allowed as a variant key and receives its own hooks when fallback selects it.
|
|
820
|
-
- Reserved guard shape keys such as `where`, `data`, `include`, and `select` cannot be variant names.
|
|
821
|
-
- OpenAPI generation extracts only the shapes; hook descriptors are never exposed as guard configuration.
|
|
822
|
-
|
|
823
|
-
`updateEach` does not support `variants`; it keeps operation-wide hooks only.
|
|
824
|
-
|
|
825
|
-
### Custom caller resolution
|
|
826
|
-
|
|
827
|
-
Use `resolveVariant` for caller logic beyond a simple header. The callback parameter type depends on the target.
|
|
828
|
-
|
|
829
|
-
```ts
|
|
830
|
-
// Express
|
|
831
|
-
const userConfig = {
|
|
832
|
-
findMany: {
|
|
833
|
-
shape: {
|
|
834
|
-
admin: { /* ... */ },
|
|
835
|
-
public: { /* ... */ },
|
|
836
|
-
},
|
|
837
|
-
},
|
|
838
|
-
guard: {
|
|
839
|
-
resolveVariant: (req) => {
|
|
840
|
-
if (req.user?.role === 'admin') return 'admin'
|
|
841
|
-
return 'public'
|
|
842
|
-
},
|
|
843
|
-
},
|
|
844
|
-
}
|
|
845
|
-
```
|
|
846
|
-
|
|
847
|
-
```ts
|
|
848
|
-
// Fastify
|
|
849
|
-
const userConfig = {
|
|
850
|
-
findMany: {
|
|
851
|
-
shape: {
|
|
852
|
-
admin: { /* ... */ },
|
|
853
|
-
public: { /* ... */ },
|
|
854
|
-
},
|
|
855
|
-
},
|
|
856
|
-
guard: {
|
|
857
|
-
resolveVariant: (request) => {
|
|
858
|
-
if (request.user?.role === 'admin') return 'admin'
|
|
859
|
-
return 'public'
|
|
860
|
-
},
|
|
861
|
-
},
|
|
862
|
-
}
|
|
863
|
-
```
|
|
864
|
-
|
|
865
|
-
```ts
|
|
866
|
-
// Hono
|
|
867
|
-
const userConfig = {
|
|
868
|
-
findMany: {
|
|
869
|
-
shape: {
|
|
870
|
-
admin: { /* ... */ },
|
|
871
|
-
public: { /* ... */ },
|
|
872
|
-
},
|
|
873
|
-
},
|
|
874
|
-
guard: {
|
|
875
|
-
resolveVariant: (c) => {
|
|
876
|
-
const user = c.get('user')
|
|
877
|
-
if (user?.role === 'admin') return 'admin'
|
|
878
|
-
return 'public'
|
|
879
|
-
},
|
|
880
|
-
},
|
|
881
|
-
}
|
|
882
|
-
```
|
|
883
|
-
|
|
884
|
-
When using `c.get('user')` or other custom context values in TypeScript, add them to the `Variables` type of your Hono app so the call is typed correctly. For example: `Hono<{ Variables: { prisma: PrismaClient; user?: { role: string } } }>`.
|
|
885
|
-
|
|
886
|
-
`resolveVariant` takes priority over the header. If both are configured, the header is checked only when `resolveVariant` returns `undefined`.
|
|
887
|
-
|
|
888
|
-
### Parameterized caller patterns
|
|
889
|
-
|
|
890
|
-
Caller keys support parameterized path patterns:
|
|
891
|
-
|
|
892
|
-
```ts
|
|
893
|
-
const projectConfig = {
|
|
894
|
-
update: {
|
|
895
|
-
variants: {
|
|
896
|
-
'/admin/projects/:id': {
|
|
897
|
-
shape: {
|
|
898
|
-
data: { title: true, status: true, priority: true },
|
|
899
|
-
where: { id: true },
|
|
900
|
-
},
|
|
901
|
-
before: [requireAdmin],
|
|
902
|
-
},
|
|
903
|
-
'/editor/projects/:id': {
|
|
904
|
-
shape: {
|
|
905
|
-
data: { title: true },
|
|
906
|
-
where: { id: true },
|
|
907
|
-
},
|
|
908
|
-
before: [requireEditor],
|
|
909
|
-
},
|
|
910
|
-
},
|
|
911
|
-
},
|
|
912
|
-
guard: {
|
|
913
|
-
variantHeader: 'x-caller',
|
|
914
|
-
},
|
|
915
|
-
}
|
|
916
|
-
```
|
|
917
|
-
|
|
918
|
-
The client sends the concrete caller:
|
|
919
|
-
|
|
920
|
-
```ts
|
|
921
|
-
fetch('/project', {
|
|
922
|
-
method: 'PUT',
|
|
923
|
-
headers: {
|
|
924
|
-
'x-caller': '/admin/projects/abc123',
|
|
925
|
-
'Content-Type': 'application/json',
|
|
926
|
-
},
|
|
927
|
-
body: JSON.stringify({
|
|
928
|
-
where: { id: 'abc123' },
|
|
929
|
-
data: { title: 'Updated', status: 'active' },
|
|
930
|
-
}),
|
|
931
|
-
})
|
|
932
|
-
```
|
|
933
|
-
|
|
934
|
-
Exact non-blank keys are checked first. Parameter segments match one path segment and are not extracted. Blank and whitespace-only callers do not exact-match or pattern-match; they use `default` or return 400.
|
|
935
|
-
|
|
936
|
-
The router stores the declared matched key, not the concrete caller. In this example:
|
|
937
|
-
|
|
938
|
-
```text
|
|
939
|
-
raw caller: /admin/projects/abc123
|
|
940
|
-
matched key: /admin/projects/:id
|
|
941
|
-
selected hooks: variants['/admin/projects/:id']
|
|
942
|
-
```
|
|
943
|
-
|
|
944
|
-
Express progressive configuration should also use the declared matched key:
|
|
945
|
-
|
|
946
|
-
```ts
|
|
947
|
-
update: {
|
|
948
|
-
variants: {
|
|
949
|
-
'/admin/projects/:id': { shape: adminShape },
|
|
950
|
-
},
|
|
951
|
-
progressive: {
|
|
952
|
-
'/admin/projects/:id': {
|
|
953
|
-
enabled: true,
|
|
954
|
-
mode: 'autoInclude',
|
|
955
|
-
},
|
|
956
|
-
},
|
|
957
|
-
}
|
|
958
|
-
```
|
|
959
|
-
|
|
960
|
-
For backward compatibility, a concrete raw-caller progressive key may be used only when no declared-key entry exists. That fallback emits a deduplicated deprecation warning identifying the declared key to use. Declared-key configuration always takes precedence.
|
|
961
|
-
|
|
962
|
-
### Forced where conditions
|
|
963
|
-
|
|
964
|
-
Literal values in `where` shapes are forced server-side and cannot be overridden by the client:
|
|
965
|
-
|
|
966
|
-
```ts
|
|
967
|
-
import { force } from 'prisma-guard'
|
|
968
|
-
|
|
969
|
-
const projectConfig = {
|
|
970
|
-
findMany: {
|
|
971
|
-
shape: {
|
|
972
|
-
default: {
|
|
973
|
-
where: {
|
|
974
|
-
status: { equals: 'published' }, // always filter to published
|
|
975
|
-
isDeleted: { equals: false }, // always exclude deleted
|
|
976
|
-
isActive: { equals: force(true) }, // force() needed for boolean true
|
|
977
|
-
title: { contains: true }, // client-controlled
|
|
978
|
-
},
|
|
979
|
-
take: { max: 50 },
|
|
980
|
-
},
|
|
981
|
-
},
|
|
982
|
-
},
|
|
983
|
-
}
|
|
984
|
-
```
|
|
985
|
-
|
|
986
|
-
A request with `{ where: { title: { contains: 'demo' } } }` produces:
|
|
987
|
-
|
|
988
|
-
```sql
|
|
989
|
-
WHERE status = 'published'
|
|
990
|
-
AND isDeleted = false
|
|
991
|
-
AND isActive = true
|
|
992
|
-
AND title LIKE '%demo%'
|
|
993
|
-
```
|
|
994
|
-
|
|
995
|
-
The client cannot bypass the forced conditions.
|
|
996
|
-
|
|
997
|
-
### Logical combinators (AND, OR, NOT)
|
|
998
|
-
|
|
999
|
-
Where shapes support `AND`, `OR`, and `NOT`. The combinator value defines which fields are allowed inside it:
|
|
1000
|
-
|
|
1001
|
-
```ts
|
|
1002
|
-
const config = {
|
|
1003
|
-
findMany: {
|
|
1004
|
-
shape: {
|
|
1005
|
-
default: {
|
|
1006
|
-
where: {
|
|
1007
|
-
OR: {
|
|
1008
|
-
title: { contains: true },
|
|
1009
|
-
description: { contains: true },
|
|
1010
|
-
},
|
|
1011
|
-
status: { equals: 'published' }, // forced, always applied
|
|
1012
|
-
},
|
|
1013
|
-
take: { max: 50 },
|
|
1014
|
-
},
|
|
1015
|
-
},
|
|
1016
|
-
},
|
|
1017
|
-
}
|
|
1018
|
-
```
|
|
1019
|
-
|
|
1020
|
-
Client sends:
|
|
1021
|
-
|
|
1022
|
-
```json
|
|
1023
|
-
{
|
|
1024
|
-
"where": {
|
|
1025
|
-
"OR": [
|
|
1026
|
-
{ "title": { "contains": "demo" } },
|
|
1027
|
-
{ "description": { "contains": "demo" } }
|
|
1028
|
-
]
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
```
|
|
1032
|
-
|
|
1033
|
-
The forced `status = 'published'` is always merged as an AND condition. Forced values inside combinators are lifted to the top-level query, regardless of the combinator type.
|
|
1034
|
-
|
|
1035
|
-
### Relation filters in where
|
|
1036
|
-
|
|
1037
|
-
Where shapes support relation-level filters. To-many relations use `some`, `every`, `none`. To-one relations use `is`, `isNot`.
|
|
1038
|
-
|
|
1039
|
-
```ts
|
|
1040
|
-
const userConfig = {
|
|
1041
|
-
findMany: {
|
|
1042
|
-
shape: {
|
|
1043
|
-
default: {
|
|
1044
|
-
where: {
|
|
1045
|
-
posts: {
|
|
1046
|
-
some: {
|
|
1047
|
-
title: { contains: true },
|
|
1048
|
-
published: { equals: true }, // forced inside the relation
|
|
1049
|
-
},
|
|
1050
|
-
},
|
|
1051
|
-
},
|
|
1052
|
-
take: { max: 50 },
|
|
1053
|
-
},
|
|
1054
|
-
},
|
|
1055
|
-
},
|
|
1056
|
-
}
|
|
1057
|
-
```
|
|
1058
|
-
|
|
1059
|
-
The client can filter by `title` inside the relation, but `published = true` is always enforced.
|
|
1060
|
-
|
|
1061
|
-
### Select and include in shapes
|
|
1062
|
-
|
|
1063
|
-
Shapes can restrict which response fields and relations the client may request:
|
|
1064
|
-
|
|
1065
|
-
```ts
|
|
1066
|
-
const userConfig = {
|
|
1067
|
-
findMany: {
|
|
1068
|
-
shape: {
|
|
1069
|
-
default: {
|
|
1070
|
-
where: { role: { equals: true } },
|
|
1071
|
-
select: {
|
|
1072
|
-
id: true,
|
|
1073
|
-
email: true,
|
|
1074
|
-
name: true,
|
|
1075
|
-
posts: {
|
|
1076
|
-
select: { id: true, title: true },
|
|
1077
|
-
},
|
|
1078
|
-
_count: {
|
|
1079
|
-
select: { posts: true },
|
|
1080
|
-
},
|
|
1081
|
-
},
|
|
1082
|
-
take: { max: 50 },
|
|
1083
|
-
},
|
|
1084
|
-
},
|
|
1085
|
-
},
|
|
1086
|
-
}
|
|
1087
|
-
```
|
|
1088
|
-
|
|
1089
|
-
The client can only select from the whitelisted fields and relations. Attempting to select unlisted fields (e.g. `passwordHash`) is rejected.
|
|
1090
|
-
|
|
1091
|
-
`select` and `include` are mutually exclusive at the same level in both the shape and the client request.
|
|
1092
|
-
|
|
1093
|
-
For read operations, the shape's `select` or `include` serves two roles: it whitelists what the client is allowed to request, and it provides the default projection when the client omits `select`/`include` from the request. If the client sends a request without `select` or `include`, the shape's projection is automatically applied — the client does not need to duplicate the field list. If the client does send `select` or `include`, it is validated against the shape as a whitelist.
|
|
1094
|
-
|
|
1095
|
-
This means a single shape declaration like the example above defines both the security boundary (which fields are allowed) and the default API response shape (which fields are returned when the client doesn't specify).
|
|
1096
|
-
|
|
1097
|
-
### Nested include with forced where and pagination
|
|
1098
|
-
|
|
1099
|
-
Nested includes on to-many relations support `where`, `orderBy`, `cursor`, `take`, and `skip`:
|
|
1100
|
-
|
|
1101
|
-
```ts
|
|
1102
|
-
import { force } from 'prisma-guard'
|
|
1103
|
-
|
|
1104
|
-
const userConfig = {
|
|
1105
|
-
findMany: {
|
|
1106
|
-
shape: {
|
|
1107
|
-
default: {
|
|
1108
|
-
include: {
|
|
1109
|
-
posts: {
|
|
1110
|
-
where: { isDeleted: { equals: false } }, // forced: never return deleted posts
|
|
1111
|
-
orderBy: { createdAt: true },
|
|
1112
|
-
take: { max: 20, default: 10 },
|
|
1113
|
-
skip: true,
|
|
1114
|
-
},
|
|
1115
|
-
profile: true, // simple include, no constraints
|
|
1116
|
-
_count: {
|
|
1117
|
-
select: {
|
|
1118
|
-
posts: {
|
|
1119
|
-
where: { isDeleted: { equals: false } }, // count only non-deleted
|
|
1120
|
-
},
|
|
1121
|
-
},
|
|
1122
|
-
},
|
|
1123
|
-
},
|
|
1124
|
-
take: { max: 50 },
|
|
1125
|
-
},
|
|
1126
|
-
},
|
|
1127
|
-
},
|
|
1128
|
-
}
|
|
1129
|
-
```
|
|
1130
|
-
|
|
1131
|
-
### Mutation return projection
|
|
1132
|
-
|
|
1133
|
-
Write operations that return records (`create`, `update`, `upsert`, `delete`, `createManyAndReturn`, `updateManyAndReturn`) support `select` and `include` in the shape:
|
|
1134
|
-
|
|
1135
|
-
```ts
|
|
1136
|
-
const userConfig = {
|
|
1137
|
-
create: {
|
|
1138
|
-
shape: {
|
|
1139
|
-
default: {
|
|
1140
|
-
data: { email: true, name: true },
|
|
1141
|
-
include: {
|
|
1142
|
-
profile: true,
|
|
1143
|
-
},
|
|
1144
|
-
},
|
|
1145
|
-
},
|
|
1146
|
-
},
|
|
1147
|
-
update: {
|
|
1148
|
-
shape: {
|
|
1149
|
-
default: {
|
|
1150
|
-
data: { name: true },
|
|
1151
|
-
where: { id: { equals: true } },
|
|
1152
|
-
select: {
|
|
1153
|
-
id: true,
|
|
1154
|
-
name: true,
|
|
1155
|
-
updatedAt: true,
|
|
1156
|
-
},
|
|
1157
|
-
},
|
|
1158
|
-
},
|
|
1159
|
-
},
|
|
1160
|
-
}
|
|
1161
|
-
```
|
|
1162
|
-
|
|
1163
|
-
The client can include `include` or `select` in the request body. If the shape does not define projection, the client cannot request one. Non-returning batch methods (`createMany`, `updateMany`, `deleteMany`) do not support projection. When `writeStrategy = "forceReturn"`, the generated `createMany` and `updateMany` endpoints invoke returning methods and can use `select`, `include`, and `omit` like `createManyAndReturn` and `updateManyAndReturn`.
|
|
1164
|
-
|
|
1165
|
-
For mutations, projection shapes only validate and constrain client-requested projections by default — if the client omits `select`/`include`, Prisma returns the full record. This differs from read operations, where the shape's projection is automatically applied as default. Enable `enforceProjection` in the prisma-guard generator config to always apply mutation projection shapes.
|
|
1166
|
-
|
|
1167
|
-
### Upsert
|
|
1168
|
-
|
|
1169
|
-
Upsert uses `create` and `update` shape keys instead of `data`:
|
|
1170
|
-
|
|
1171
|
-
```ts
|
|
1172
|
-
import { force } from 'prisma-guard'
|
|
1173
|
-
|
|
1174
|
-
const projectConfig = {
|
|
1175
|
-
upsert: {
|
|
1176
|
-
shape: {
|
|
1177
|
-
default: {
|
|
1178
|
-
where: { id: { equals: true } },
|
|
1179
|
-
create: {
|
|
1180
|
-
title: true,
|
|
1181
|
-
status: 'draft',
|
|
1182
|
-
isActive: force(true),
|
|
1183
|
-
},
|
|
1184
|
-
update: {
|
|
1185
|
-
title: true,
|
|
1186
|
-
},
|
|
1187
|
-
select: { id: true, title: true, status: true },
|
|
1188
|
-
},
|
|
1189
|
-
},
|
|
1190
|
-
},
|
|
1191
|
-
}
|
|
1192
|
-
```
|
|
1193
|
-
|
|
1194
|
-
All three (`where`, `create`, `update`) are required. Using `data` instead of `create`/`update` is rejected.
|
|
1195
|
-
|
|
1196
|
-
### Bulk mutation safety
|
|
1197
|
-
|
|
1198
|
-
`updateMany`, `updateManyAndReturn`, and `deleteMany` require `where` in the shape:
|
|
1199
|
-
|
|
1200
|
-
```ts
|
|
1201
|
-
const userConfig = {
|
|
1202
|
-
deleteMany: {
|
|
1203
|
-
shape: {
|
|
1204
|
-
default: {
|
|
1205
|
-
where: { isActive: { equals: true }, role: { equals: true } },
|
|
1206
|
-
},
|
|
1207
|
-
},
|
|
1208
|
-
},
|
|
1209
|
-
updateMany: {
|
|
1210
|
-
shape: {
|
|
1211
|
-
default: {
|
|
1212
|
-
data: { isActive: true },
|
|
1213
|
-
where: { role: { equals: true } },
|
|
1214
|
-
},
|
|
1215
|
-
},
|
|
1216
|
-
},
|
|
1217
|
-
}
|
|
1218
|
-
```
|
|
1219
|
-
|
|
1220
|
-
A shape without `where` on these methods is rejected. Empty resolved where at runtime is also rejected.
|
|
1221
|
-
|
|
1222
|
-
### Tenant isolation with guard shapes
|
|
1223
|
-
|
|
1224
|
-
When the guard extension is configured with scope context, tenant filters are injected automatically into all top-level operations on scoped models. Guard shapes and scope work together:
|
|
1225
|
-
|
|
1226
|
-
```prisma
|
|
1227
|
-
/// @scope-root
|
|
1228
|
-
model Tenant {
|
|
1229
|
-
id String @id @default(cuid())
|
|
1230
|
-
name String
|
|
1231
|
-
projects Project[]
|
|
1232
|
-
}
|
|
1233
|
-
|
|
1234
|
-
model Project {
|
|
1235
|
-
id String @id @default(cuid())
|
|
1236
|
-
title String
|
|
1237
|
-
tenantId String
|
|
1238
|
-
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
1239
|
-
}
|
|
1240
|
-
```
|
|
1241
|
-
|
|
1242
|
-
```ts
|
|
1243
|
-
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
1244
|
-
import { guard } from './generated/guard/client'
|
|
1245
|
-
|
|
1246
|
-
const store = new AsyncLocalStorage<{ tenantId: string }>()
|
|
1247
|
-
|
|
1248
|
-
const prisma = new PrismaClient().$extends(
|
|
1249
|
-
guard.extension(() => ({
|
|
1250
|
-
Tenant: store.getStore()?.tenantId,
|
|
1251
|
-
}))
|
|
1252
|
-
)
|
|
1253
|
-
|
|
1254
|
-
app.use(express.json())
|
|
1255
|
-
|
|
1256
|
-
app.use((req, res, next) => {
|
|
1257
|
-
const tenantId = req.headers['x-tenant-id'] as string
|
|
1258
|
-
store.run({ tenantId }, () => {
|
|
1259
|
-
req.prisma = prisma
|
|
1260
|
-
next()
|
|
1261
|
-
})
|
|
1262
|
-
})
|
|
1263
|
-
|
|
1264
|
-
app.use('/', ProjectRouter({
|
|
1265
|
-
findMany: {
|
|
1266
|
-
shape: {
|
|
1267
|
-
default: {
|
|
1268
|
-
where: { title: { contains: true } },
|
|
1269
|
-
take: { max: 50 },
|
|
1270
|
-
},
|
|
1271
|
-
},
|
|
1272
|
-
},
|
|
1273
|
-
create: {
|
|
1274
|
-
shape: {
|
|
1275
|
-
default: {
|
|
1276
|
-
data: { title: true },
|
|
1277
|
-
},
|
|
1278
|
-
},
|
|
1279
|
-
},
|
|
1280
|
-
}))
|
|
1281
|
-
```
|
|
1282
|
-
|
|
1283
|
-
The scope extension handles tenant isolation at the query level:
|
|
1284
|
-
|
|
1285
|
-
- Reads: `AND tenantId = ?` is injected into where
|
|
1286
|
-
- Creates: `tenantId` is injected into data (the scope FK does not need to be in the data shape)
|
|
1287
|
-
- Updates/deletes: `tenantId` condition is merged into where, scope FK is stripped from data
|
|
1288
|
-
- Upsert: scope condition in where, FK injected into create data, FK stripped from update data
|
|
1289
|
-
|
|
1290
|
-
The data shape for `create` above only lists `title`. The `tenantId` field is injected by the scope extension automatically — the create completeness check accounts for scope foreign keys.
|
|
1291
|
-
|
|
1292
|
-
### Supported shape keys
|
|
1293
|
-
|
|
1294
|
-
For reads: `where`, `include`, `select`, `orderBy`, `cursor`, `take`, `skip`, `distinct`, `_count`, `_avg`, `_sum`, `_min`, `_max`, `by`, `having`
|
|
1295
|
-
|
|
1296
|
-
For writes: `data`, `where`, `select`, `include` (select/include only on methods that return records)
|
|
1297
|
-
|
|
1298
|
-
For upsert: `where`, `create`, `update`, `select`, `include`
|
|
1299
|
-
|
|
1300
|
-
### Guard error handling
|
|
1301
|
-
|
|
1302
|
-
Guard errors are mapped to HTTP status codes by the generated error handler:
|
|
1303
|
-
|
|
1304
|
-
| Error type | HTTP status | When |
|
|
1305
|
-
| ------------- | ----------- | ----------------------------------------------------------------- |
|
|
1306
|
-
| `ShapeError` | 400 | Invalid shape config, unknown fields, body validation, type errors |
|
|
1307
|
-
| `CallerError` | 400 | Missing/unknown/ambiguous caller, caller in body |
|
|
1308
|
-
| `PolicyError` | 403 | Scope denied, missing tenant context, rejected findUnique |
|
|
1309
|
-
|
|
1310
|
-
Generated caller routing preserves the same 400 messages for existing named `shape` maps. Missing, unknown, ambiguous, and reserved-key routing failures are stored during shape preparation, operation before-hooks run, and the failure is then surfaced before variant hooks and the handler.
|
|
1311
|
-
|
|
1312
|
-
The new `variants` descriptor is validated when the router is constructed. Invalid configuration such as an empty variants map, a missing entry shape, a reserved variant name, or defining both `shape` and `variants` throws immediately instead of registering the route.
|
|
1313
|
-
|
|
1314
|
-
All request errors return `{ "message": "..." }` in the response body.
|
|
1315
|
-
|
|
1316
|
-
### Complete guard example
|
|
1317
|
-
|
|
1318
|
-
```ts
|
|
1319
|
-
import express from 'express'
|
|
1320
|
-
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
1321
|
-
import { PrismaClient } from '@prisma/client'
|
|
1322
|
-
import { guard } from './generated/guard/client'
|
|
1323
|
-
import { force } from 'prisma-guard'
|
|
1324
|
-
import { UserRouter } from './generated/User/UserRouter'
|
|
1325
|
-
import { ProjectRouter } from './generated/Project/ProjectRouter'
|
|
1326
|
-
|
|
1327
|
-
const store = new AsyncLocalStorage<{ tenantId: string; role: string }>()
|
|
1328
|
-
|
|
1329
|
-
const prisma = new PrismaClient().$extends(
|
|
1330
|
-
guard.extension(() => ({
|
|
1331
|
-
Tenant: store.getStore()?.tenantId,
|
|
1332
|
-
}))
|
|
1333
|
-
)
|
|
1334
|
-
|
|
1335
|
-
const app = express()
|
|
1336
|
-
|
|
1337
|
-
app.use(express.json())
|
|
1338
|
-
|
|
1339
|
-
app.use((req, res, next) => {
|
|
1340
|
-
const tenantId = req.headers['x-tenant-id'] as string
|
|
1341
|
-
const role = req.headers['x-role'] as string || 'viewer'
|
|
1342
|
-
store.run({ tenantId, role }, () => {
|
|
1343
|
-
req.prisma = prisma
|
|
1344
|
-
next()
|
|
1345
|
-
})
|
|
1346
|
-
})
|
|
1347
|
-
|
|
1348
|
-
app.use('/', ProjectRouter({
|
|
1349
|
-
findMany: {
|
|
1350
|
-
shape: {
|
|
1351
|
-
admin: {
|
|
1352
|
-
where: { title: { contains: true }, status: { equals: true } },
|
|
1353
|
-
include: { members: true },
|
|
1354
|
-
orderBy: { createdAt: true },
|
|
1355
|
-
take: { max: 200 },
|
|
1356
|
-
skip: true,
|
|
1357
|
-
},
|
|
1358
|
-
viewer: {
|
|
1359
|
-
where: {
|
|
1360
|
-
title: { contains: true },
|
|
1361
|
-
status: { equals: 'published' },
|
|
1362
|
-
isDeleted: { equals: false },
|
|
1363
|
-
},
|
|
1364
|
-
select: { id: true, title: true, createdAt: true },
|
|
1365
|
-
take: { max: 50, default: 20 },
|
|
1366
|
-
},
|
|
1367
|
-
},
|
|
1368
|
-
},
|
|
1369
|
-
create: {
|
|
1370
|
-
shape: {
|
|
1371
|
-
admin: {
|
|
1372
|
-
data: { title: true, status: true, priority: true },
|
|
1373
|
-
include: { members: true },
|
|
1374
|
-
},
|
|
1375
|
-
viewer: {
|
|
1376
|
-
data: { title: true, status: 'draft', priority: 1 },
|
|
1377
|
-
},
|
|
1378
|
-
},
|
|
1379
|
-
},
|
|
1380
|
-
update: {
|
|
1381
|
-
shape: {
|
|
1382
|
-
admin: {
|
|
1383
|
-
data: { title: true, status: true, priority: true },
|
|
1384
|
-
where: { id: { equals: true } },
|
|
1385
|
-
},
|
|
1386
|
-
viewer: {
|
|
1387
|
-
data: { title: true },
|
|
1388
|
-
where: { id: { equals: true } },
|
|
1389
|
-
},
|
|
1390
|
-
},
|
|
1391
|
-
},
|
|
1392
|
-
delete: {
|
|
1393
|
-
shape: {
|
|
1394
|
-
admin: {
|
|
1395
|
-
where: { id: { equals: true } },
|
|
1396
|
-
},
|
|
1397
|
-
},
|
|
1398
|
-
},
|
|
1399
|
-
guard: {
|
|
1400
|
-
resolveVariant: (req) => {
|
|
1401
|
-
const ctx = store.getStore()
|
|
1402
|
-
return ctx?.role === 'admin' ? 'admin' : 'viewer'
|
|
1403
|
-
},
|
|
1404
|
-
},
|
|
1405
|
-
}))
|
|
1406
|
-
|
|
1407
|
-
app.listen(3000)
|
|
1408
|
-
```
|
|
1409
|
-
|
|
1410
|
-
In this setup:
|
|
1411
|
-
|
|
1412
|
-
- Admins can filter by any allowed field, include relations, and take up to 200 rows
|
|
1413
|
-
- Viewers can only see published, non-deleted projects with a restricted field set — the `select` shape automatically applies as the default projection, so viewer clients don't need to send `select` in the request
|
|
1414
|
-
- Create: admins set any allowed field; viewers always create drafts with priority 1
|
|
1415
|
-
- Delete: only admins can delete; viewers hitting the delete endpoint get a `CallerError` because there is no `viewer` shape for delete
|
|
1416
|
-
- Tenant isolation is automatic — every query is scoped to the tenant from `x-tenant-id`
|
|
1417
|
-
|
|
1418
|
-
## Request body format
|
|
1419
|
-
|
|
1420
|
-
All write operations accept the full Prisma args object as the JSON request body. The body must be a JSON object — sending `null`, arrays, or other non-object values returns 400.
|
|
1421
|
-
|
|
1422
|
-
```ts
|
|
1423
|
-
// Create
|
|
1424
|
-
{ "data": { "name": "Alice", "email": "alice@example.com" }, "select": { "id": true } }
|
|
1425
|
-
|
|
1426
|
-
// Update
|
|
1427
|
-
{ "where": { "id": 1 }, "data": { "name": "Bob" } }
|
|
1428
|
-
|
|
1429
|
-
// Delete
|
|
1430
|
-
{ "where": { "id": 1 } }
|
|
1431
|
-
|
|
1432
|
-
// Upsert
|
|
1433
|
-
{ "where": { "id": 1 }, "create": { "name": "Alice" }, "update": { "name": "Bob" } }
|
|
1434
|
-
```
|
|
1435
|
-
|
|
1436
|
-
Write operations that return records (`create`, `update`, `delete`, `upsert`, `createManyAndReturn`, `updateManyAndReturn`) support `select`, `include`, and `omit` in the request body to control the response shape. When `writeStrategy = "forceReturn"`, the generated `createMany` and `updateMany` endpoints are rewritten to returning methods and also support `select`, `include`, and `omit`.
|
|
1437
|
-
|
|
1438
|
-
For Express, mount `express.json()` before the router so request bodies are parsed. For Hono, malformed JSON bodies and non-object bodies are rejected with 400 (`{ "message": "Request body must be a JSON object" }`) before reaching the handler.
|
|
1439
|
-
|
|
1440
|
-
### Bulk operations
|
|
1441
|
-
|
|
1442
|
-
`createMany`, `createManyAndReturn`, `updateMany`, and `updateManyAndReturn` accept scalar-only data inputs. Nested relation writes are not supported in bulk operations.
|
|
1443
|
-
|
|
1444
|
-
By default, `createMany` and `updateMany` return `{ count }`, while `createManyAndReturn` and `updateManyAndReturn` return arrays of records. With `writeStrategy = "forceReturn"`, the generated `createMany` and `updateMany` endpoints return arrays of records because they invoke the returning Prisma methods internally.
|
|
1445
|
-
|
|
1446
|
-
### Batch operation safety
|
|
1447
|
-
|
|
1448
|
-
`deleteMany`, `updateMany`, and `updateManyAndReturn` require a `where` field in the request body. Requests without `where` are rejected with 400 to prevent accidental mass operations. Sending `{ "where": {} }` is valid and matches all records — this protection catches accidental omission, not intentional broad operations.
|
|
1449
|
-
|
|
1450
|
-
## Query encoding (client side)
|
|
1451
|
-
|
|
1452
|
-
```ts
|
|
1453
|
-
import { encodeQueryParams } from './generated/client/encodeQueryParams'
|
|
1454
|
-
|
|
1455
|
-
const params = encodeQueryParams({
|
|
1456
|
-
where: { status: 'active', role: { in: ['admin', 'editor'] } },
|
|
1457
|
-
select: { id: true, email: true },
|
|
1458
|
-
take: 20,
|
|
1459
|
-
})
|
|
1460
|
-
|
|
1461
|
-
const response = await fetch(`/user?${params}`)
|
|
1462
|
-
```
|
|
1463
|
-
|
|
1464
|
-
Complex values (`where`, `select`, `include`, `omit`, `orderBy`) are JSON-stringified. Primitives (`take`, `skip`) are sent directly. The encoder handles BigInt serialization automatically.
|
|
1465
|
-
|
|
1466
|
-
## POST read endpoints
|
|
1467
|
-
|
|
1468
|
-
All read operations are available via POST in addition to GET. POST read endpoints accept the same arguments as their GET counterparts, but as a JSON request body instead of query parameters. This is useful when complex filters, deeply nested `where` clauses, or large `select`/`include` objects exceed URL length limits (typically 2048–8192 characters depending on server, proxy, and CDN configuration).
|
|
1469
|
-
|
|
1470
|
-
POST read endpoints are enabled by default. Disable them with `disablePostReads: true` in the route config.
|
|
1471
|
-
|
|
1472
|
-
### Path mapping
|
|
1473
|
-
|
|
1474
|
-
Most read operations use the same path for both GET and POST. The only exception is `findMany`, which uses a `/read` suffix to avoid conflicting with `POST /` (create).
|
|
1475
|
-
|
|
1476
|
-
`{modelname}` in the paths below is the lowercased model name. See [Path casing in generated endpoints](#path-casing-in-generated-endpoints).
|
|
1477
|
-
|
|
1478
|
-
| Operation | GET path | POST path |
|
|
1479
|
-
| ----------------- | ---------------------------- | ---------------------------- |
|
|
1480
|
-
| findMany | `/{modelname}/` | `/{modelname}/read` |
|
|
1481
|
-
| findFirst | `/{modelname}/first` | `/{modelname}/first` |
|
|
1482
|
-
| findFirstOrThrow | `/{modelname}/first/strict` | `/{modelname}/first/strict` |
|
|
1483
|
-
| findUnique | `/{modelname}/unique` | `/{modelname}/unique` |
|
|
1484
|
-
| findUniqueOrThrow | `/{modelname}/unique/strict` | `/{modelname}/unique/strict` |
|
|
1485
|
-
| findManyPaginated | `/{modelname}/paginated` | `/{modelname}/paginated` |
|
|
1486
|
-
| count | `/{modelname}/count` | `/{modelname}/count` |
|
|
1487
|
-
| aggregate | `/{modelname}/aggregate` | `/{modelname}/aggregate` |
|
|
1488
|
-
| groupBy | `/{modelname}/groupby` | `/{modelname}/groupby` |
|
|
1489
|
-
|
|
1490
|
-
### Usage
|
|
1491
|
-
|
|
1492
|
-
With GET and `encodeQueryParams`:
|
|
1493
|
-
|
|
1494
|
-
```ts
|
|
1495
|
-
import { encodeQueryParams } from './generated/client/encodeQueryParams'
|
|
1496
|
-
|
|
1497
|
-
const params = encodeQueryParams({
|
|
1498
|
-
where: { status: 'active', role: { in: ['admin', 'editor'] } },
|
|
1499
|
-
select: { id: true, email: true },
|
|
1500
|
-
take: 20,
|
|
1501
|
-
})
|
|
1502
|
-
|
|
1503
|
-
const response = await fetch(`/user?${params}`)
|
|
1504
|
-
```
|
|
1505
|
-
|
|
1506
|
-
With POST — same args, no encoding needed:
|
|
1507
|
-
|
|
1508
|
-
```ts
|
|
1509
|
-
const response = await fetch('/user/read', {
|
|
1510
|
-
method: 'POST',
|
|
1511
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1512
|
-
body: JSON.stringify({
|
|
1513
|
-
where: { status: 'active', role: { in: ['admin', 'editor'] } },
|
|
1514
|
-
select: { id: true, email: true },
|
|
1515
|
-
take: 20,
|
|
1516
|
-
}),
|
|
1517
|
-
})
|
|
1518
|
-
```
|
|
1519
|
-
|
|
1520
|
-
### Differences from GET
|
|
1521
|
-
|
|
1522
|
-
POST read bodies use native JSON types directly — numbers are numbers, booleans are booleans, objects are objects. There is no JSON-string encoding of nested values as with GET query parameters, and no string-to-type coercion is applied. The `encodeQueryParams` utility is not needed for POST reads.
|
|
1523
|
-
|
|
1524
|
-
### Guard shapes
|
|
1525
|
-
|
|
1526
|
-
POST read endpoints use the same guard shapes, hooks, and middleware as their GET counterparts. The same `before`/`after` hooks run for both GET and POST on the same operation.
|
|
1527
|
-
|
|
1528
|
-
### Disabling
|
|
1529
|
-
|
|
1530
|
-
```ts
|
|
1531
|
-
app.use('/', UserRouter({
|
|
1532
|
-
enableAll: true,
|
|
1533
|
-
disablePostReads: true,
|
|
1534
|
-
}))
|
|
1535
|
-
```
|
|
1536
|
-
|
|
1537
|
-
## Materialized views router (Express)
|
|
1538
|
-
|
|
1539
|
-
The Express target includes a standalone helper for read-only access to PostgreSQL materialized views.
|
|
1540
|
-
|
|
1541
|
-
Materialized views are not Prisma models and do not have Prisma delegates, so they are not generated as normal per-model routers. Instead, mount one standalone router and provide an explicit registry of allowed views.
|
|
1542
|
-
|
|
1543
|
-
This feature is **Express-only**.
|
|
1544
|
-
|
|
1545
|
-
### Usage
|
|
1546
|
-
|
|
1547
|
-
```ts
|
|
1548
|
-
import express from 'express'
|
|
1549
|
-
import { PrismaClient } from '@prisma/client'
|
|
1550
|
-
import { materializedViewsRouter } from './generated/materializedRouter'
|
|
1551
|
-
|
|
1552
|
-
const prisma = new PrismaClient()
|
|
1553
|
-
const app = express()
|
|
1554
|
-
|
|
1555
|
-
app.use('/api', materializedViewsRouter({
|
|
1556
|
-
prisma,
|
|
1557
|
-
basePath: '/materialized',
|
|
1558
|
-
views: {
|
|
1559
|
-
jobAdStats: {
|
|
1560
|
-
relation: 'mv_jobad_stats',
|
|
1561
|
-
orderBy: {
|
|
1562
|
-
field: 'updatedAt',
|
|
1563
|
-
direction: 'desc',
|
|
1564
|
-
},
|
|
1565
|
-
},
|
|
1566
|
-
companyStats: {
|
|
1567
|
-
relation: 'mv_company_stats',
|
|
1568
|
-
},
|
|
1569
|
-
},
|
|
1570
|
-
}))
|
|
1571
|
-
|
|
1572
|
-
app.listen(3000)
|
|
1573
|
-
```
|
|
1574
|
-
|
|
1575
|
-
This registers:
|
|
1576
|
-
|
|
1577
|
-
```http
|
|
1578
|
-
GET /api/materialized/jobAdStats?take=50&skip=0
|
|
1579
|
-
GET /api/materialized/companyStats?take=50
|
|
1580
|
-
```
|
|
1581
|
-
|
|
1582
|
-
### View registry
|
|
1583
|
-
|
|
1584
|
-
Each key in `views` is the public API name. The `relation` value is the actual database relation name.
|
|
1585
|
-
|
|
1586
|
-
```ts
|
|
1587
|
-
views: {
|
|
1588
|
-
jobAdStats: {
|
|
1589
|
-
relation: 'mv_jobad_stats',
|
|
1590
|
-
schema: 'public',
|
|
1591
|
-
defaultLimit: 50,
|
|
1592
|
-
maxLimit: 500,
|
|
1593
|
-
orderBy: {
|
|
1594
|
-
field: 'updatedAt',
|
|
1595
|
-
direction: 'desc',
|
|
1596
|
-
nulls: 'last',
|
|
1597
|
-
},
|
|
1598
|
-
},
|
|
1599
|
-
}
|
|
1600
|
-
```
|
|
1601
|
-
|
|
1602
|
-
Supported view options:
|
|
1603
|
-
|
|
1604
|
-
| Option | Type | Description |
|
|
1605
|
-
| ------ | ---- | ----------- |
|
|
1606
|
-
| `relation` | `string` | Database materialized view name |
|
|
1607
|
-
| `schema` | `string` | Optional schema name |
|
|
1608
|
-
| `defaultLimit` | `number` | Default page size for this view |
|
|
1609
|
-
| `maxLimit` | `number` | Maximum page size for this view |
|
|
1610
|
-
| `orderBy` | `string \| object` | Deterministic default sort column |
|
|
1611
|
-
| `allowedOrderBy` | `string[]` | Optional allowlist for client-provided `orderBy` query fields |
|
|
1612
|
-
| `authorize` | `function` | Optional per-view authorization hook |
|
|
1613
|
-
|
|
1614
|
-
`orderBy` can be a string:
|
|
1615
|
-
|
|
1616
|
-
```ts
|
|
1617
|
-
orderBy: 'updatedAt'
|
|
1618
|
-
```
|
|
1619
|
-
|
|
1620
|
-
Or an object:
|
|
1621
|
-
|
|
1622
|
-
```ts
|
|
1623
|
-
orderBy: {
|
|
1624
|
-
field: 'updatedAt',
|
|
1625
|
-
direction: 'desc',
|
|
1626
|
-
nulls: 'last',
|
|
1627
|
-
}
|
|
1628
|
-
```
|
|
1629
|
-
|
|
1630
|
-
### Router options
|
|
1631
|
-
|
|
1632
|
-
```ts
|
|
1633
|
-
materializedViewsRouter({
|
|
1634
|
-
prisma,
|
|
1635
|
-
basePath: '/materialized',
|
|
1636
|
-
defaultLimit: 50,
|
|
1637
|
-
maxLimit: 1000,
|
|
1638
|
-
before: [authMiddleware],
|
|
1639
|
-
after: [auditMiddleware],
|
|
1640
|
-
views: {
|
|
1641
|
-
jobAdStats: { relation: 'mv_jobad_stats' },
|
|
1642
|
-
},
|
|
1643
|
-
})
|
|
1644
|
-
```
|
|
1645
|
-
|
|
1646
|
-
Supported router options:
|
|
1647
|
-
|
|
1648
|
-
| Option | Type | Description |
|
|
1649
|
-
| ------ | ---- | ----------- |
|
|
1650
|
-
| `prisma` | Prisma client-like object | Must expose `$queryRawUnsafe` |
|
|
1651
|
-
| `views` | `Record<string, ViewDef>` | Registry of allowed materialized views |
|
|
1652
|
-
| `basePath` | `string` | Path inside this router, default `''` |
|
|
1653
|
-
| `defaultLimit` | `number` | Global default page size, default `50` |
|
|
1654
|
-
| `maxLimit` | `number` | Global max page size, default `1000` |
|
|
1655
|
-
| `before` | `RequestHandler[]` | Express middleware before the query |
|
|
1656
|
-
| `after` | `RequestHandler[]` | Express middleware after the query |
|
|
1657
|
-
|
|
1658
|
-
### Authorization
|
|
1659
|
-
|
|
1660
|
-
Use `before` for shared middleware and `authorize` for per-view checks.
|
|
1661
|
-
|
|
1662
|
-
```ts
|
|
1663
|
-
const forbidden = (message: string) => Object.assign(new Error(message), { status: 403 })
|
|
1664
|
-
|
|
1665
|
-
app.use('/api', materializedViewsRouter({
|
|
1666
|
-
prisma,
|
|
1667
|
-
basePath: '/materialized',
|
|
1668
|
-
before: [requireAuth],
|
|
1669
|
-
views: {
|
|
1670
|
-
publicStats: {
|
|
1671
|
-
relation: 'mv_public_stats',
|
|
1672
|
-
},
|
|
1673
|
-
adminStats: {
|
|
1674
|
-
relation: 'mv_admin_stats',
|
|
1675
|
-
authorize: (req) => {
|
|
1676
|
-
if (req.user?.role !== 'admin') {
|
|
1677
|
-
throw forbidden('Forbidden')
|
|
1678
|
-
}
|
|
1679
|
-
},
|
|
1680
|
-
},
|
|
1681
|
-
},
|
|
1682
|
-
}))
|
|
1683
|
-
```
|
|
1684
|
-
|
|
1685
|
-
If a view is not in the registry, the router returns:
|
|
1686
|
-
|
|
1687
|
-
```json
|
|
1688
|
-
{ "message": "unknown view" }
|
|
1689
|
-
```
|
|
1690
|
-
|
|
1691
|
-
### Pagination
|
|
1692
|
-
|
|
1693
|
-
The router supports `take` and `skip` query parameters:
|
|
1694
|
-
|
|
1695
|
-
```http
|
|
1696
|
-
GET /materialized/jobAdStats?take=100&skip=200
|
|
1697
|
-
```
|
|
1698
|
-
|
|
1699
|
-
`take` is clamped to the configured max limit. `skip` is clamped to zero or greater.
|
|
1700
|
-
|
|
1701
|
-
Clients may provide an `orderBy` query parameter. If `allowedOrderBy` is set, client-provided `orderBy` must be one of those fields. If `allowedOrderBy` is omitted, any valid SQL identifier is accepted and quoted.
|
|
1702
|
-
|
|
1703
|
-
When `skip > 0`, the view must define `orderBy` or receive a valid client `orderBy`. This prevents unstable offset pagination.
|
|
1704
|
-
|
|
1705
|
-
```ts
|
|
1706
|
-
views: {
|
|
1707
|
-
jobAdStats: {
|
|
1708
|
-
relation: 'mv_jobad_stats',
|
|
1709
|
-
orderBy: {
|
|
1710
|
-
field: 'updatedAt',
|
|
1711
|
-
direction: 'desc',
|
|
1712
|
-
},
|
|
1713
|
-
},
|
|
1714
|
-
}
|
|
1715
|
-
```
|
|
1716
|
-
|
|
1717
|
-
### Identifier safety
|
|
1718
|
-
|
|
1719
|
-
Only registered view names can be queried. Database identifiers such as `schema`, `relation`, `orderBy.field`, and `allowedOrderBy` entries must match this pattern:
|
|
1720
|
-
|
|
1721
|
-
```txt
|
|
1722
|
-
^[A-Za-z_][A-Za-z0-9_]*$
|
|
1723
|
-
```
|
|
1724
|
-
|
|
1725
|
-
Identifiers are double-quoted before being used in SQL.
|
|
1726
|
-
|
|
1727
|
-
This means camel-case database columns must exist as quoted identifiers. For example, `orderBy: 'updatedAt'` queries `"updatedAt"`. If the materialized view was created without a quoted alias, PostgreSQL stores the column as `updatedat`.
|
|
1728
|
-
|
|
1729
|
-
### Response serialization
|
|
1730
|
-
|
|
1731
|
-
Responses use the same serialization behavior as generated routers:
|
|
1732
|
-
|
|
1733
|
-
- `BigInt` values become strings
|
|
1734
|
-
- `Decimal` values become strings
|
|
1735
|
-
- `Buffer` and `Uint8Array` values become base64 strings
|
|
1736
|
-
- `DateTime` values become ISO 8601 strings
|
|
1737
|
-
|
|
1738
|
-
### Limitations
|
|
1739
|
-
|
|
1740
|
-
The materialized views router is intentionally small and read-only.
|
|
1741
|
-
|
|
1742
|
-
It does not support:
|
|
1743
|
-
|
|
1744
|
-
- Prisma `where`
|
|
1745
|
-
- Prisma `select`
|
|
1746
|
-
- Prisma `include`
|
|
1747
|
-
- Prisma guard shapes
|
|
1748
|
-
- OpenAPI generation
|
|
1749
|
-
- Fastify or Hono targets
|
|
1750
|
-
- refreshing materialized views
|
|
1751
|
-
|
|
1752
|
-
Use it for explicit read-only endpoints over known materialized views. For normal Prisma models, use the generated model routers.
|
|
1753
|
-
|
|
1754
|
-
## Progressive Endpoint Composition (Express SSE)
|
|
1755
|
-
|
|
1756
|
-
Progressive Endpoint Composition lets an Express read endpoint stream partial response fields over Server-Sent Events while still ending with a final result event.
|
|
1757
|
-
|
|
1758
|
-
This is useful for page-level endpoints where different UI sections need different slices of data. For example, a dashboard can render profile basics first, then saved jobs, applications, invitations, and activity as each part finishes.
|
|
1759
|
-
|
|
1760
|
-
This feature is **Express-only** in v1.
|
|
1761
|
-
|
|
1762
|
-
### Mental model
|
|
1763
|
-
|
|
1764
|
-
Progressive SSE has two modes:
|
|
1765
|
-
|
|
1766
|
-
| Mode | Config | Best for |
|
|
1767
|
-
| ---- | ------ | -------- |
|
|
1768
|
-
| Manual stages | `{ stages: [...] }` or `{ mode: 'manual', stages: [...] }` | Custom page-level composition where each stage runs its own query and returns patches |
|
|
1769
|
-
| Auto include | `{ mode: 'autoInclude' }` | Reads where the client already sends a Prisma `include` or relation `select` tree and you want relation fields streamed progressively |
|
|
1770
|
-
|
|
1771
|
-
Manual mode is explicit staged data loading. You define stages yourself and each stage decides what query to run and which field path to patch.
|
|
1772
|
-
|
|
1773
|
-
Auto-include mode is generated relation loading. The router keeps the normal GET endpoint, runs the root query first, then loads supported included relations as separate follow-up queries. For single-record reads, relation paths are streamed as field patches. For `findMany` and `findManyPaginated`, root rows are streamed first, direct root relation stages are streamed as index-aligned relation batches, and deeper nested relation stages are streamed as locator-based nested relation batches. The terminal `result` event still contains the fully assembled payload.
|
|
1774
|
-
|
|
1775
|
-
### Request format
|
|
1776
|
-
|
|
1777
|
-
Use the same generated GET read endpoint and request SSE with the `Accept` header:
|
|
1778
|
-
|
|
1779
|
-
```http
|
|
1780
|
-
GET /user/first
|
|
1781
|
-
Accept: text/event-stream
|
|
1782
|
-
x-api-variant: /talent/dashboard
|
|
1783
|
-
```
|
|
1784
|
-
|
|
1785
|
-
No new endpoint is generated. The variant is resolved the same way as guard variants: `guard.resolveVariant(req)` first, then the configured header, defaulting to `x-api-variant`.
|
|
1786
|
-
|
|
1787
|
-
If a GET read request accepts `text/event-stream` but the matched variant has no progressive config, the router runs the normal read query and returns a single SSE `result` event.
|
|
1788
|
-
|
|
1789
|
-
POST read endpoints remain JSON-only.
|
|
1790
|
-
|
|
1791
|
-
### Supported operations
|
|
1792
|
-
|
|
1793
|
-
Manual progressive SSE can be configured on Express GET read operations only:
|
|
1794
|
-
|
|
1795
|
-
- `findMany`
|
|
1796
|
-
- `findUnique`
|
|
1797
|
-
- `findUniqueOrThrow`
|
|
1798
|
-
- `findFirst`
|
|
1799
|
-
- `findFirstOrThrow`
|
|
1800
|
-
- `findManyPaginated`
|
|
1801
|
-
- `count`
|
|
1802
|
-
- `aggregate`
|
|
1803
|
-
- `groupBy`
|
|
1804
|
-
|
|
1805
|
-
Auto-include progressive SSE supports these Express GET read operations when no guard shape is attached:
|
|
1806
|
-
|
|
1807
|
-
- `findUnique`
|
|
1808
|
-
- `findUniqueOrThrow`
|
|
1809
|
-
- `findFirst`
|
|
1810
|
-
- `findFirstOrThrow`
|
|
1811
|
-
- `findMany`
|
|
1812
|
-
- `findManyPaginated`
|
|
1813
|
-
|
|
1814
|
-
Unguarded `findMany` and `findManyPaginated` support deep relation loading by flattening parent rows at each relation path, with fallback cases listed in [Auto-include behavior and limits](#auto-include-behavior-and-limits).
|
|
1815
|
-
|
|
1816
|
-
When a prisma-guard shape is attached, auto-include supports these single-record read operations:
|
|
1817
|
-
|
|
1818
|
-
- `findUnique`
|
|
1819
|
-
- `findUniqueOrThrow`
|
|
1820
|
-
- `findFirst`
|
|
1821
|
-
- `findFirstOrThrow`
|
|
1822
|
-
|
|
1823
|
-
Guarded `findMany` and `findManyPaginated` continue to use the configured fallback behavior. Normal guarded JSON reads are not affected.
|
|
1824
|
-
|
|
1825
|
-
If auto-include is configured on an unsupported operation, the router either falls back to single-result SSE or sends an SSE error depending on `fallback`.
|
|
1826
|
-
|
|
1827
|
-
Write operations do not support progressive SSE.
|
|
1828
|
-
|
|
1829
|
-
### Event protocol
|
|
1830
|
-
|
|
1831
|
-
Each event is sent as a normal SSE `data:` line containing JSON.
|
|
1832
|
-
|
|
1833
|
-
Progress event:
|
|
1834
|
-
|
|
1835
|
-
```json
|
|
1836
|
-
{ "type": "progress", "stage": "profileBasics", "completed": 1, "total": 4 }
|
|
1837
|
-
```
|
|
1838
|
-
|
|
1839
|
-
Field event:
|
|
1840
|
-
|
|
1841
|
-
```json
|
|
1842
|
-
{ "type": "field", "key": "profile", "value": { "id": "profile-id" } }
|
|
1843
|
-
```
|
|
1844
|
-
|
|
1845
|
-
Nested field event:
|
|
1846
|
-
|
|
1847
|
-
```json
|
|
1848
|
-
{ "type": "field", "key": "profile.appliedTo", "value": [] }
|
|
1849
|
-
```
|
|
1850
|
-
|
|
1851
|
-
Root array event for `findMany` / `findManyPaginated` auto-include:
|
|
1852
|
-
|
|
1853
|
-
```json
|
|
1854
|
-
{ "type": "rootArray", "data": [{ "id": "user-1" }, { "id": "user-2" }] }
|
|
1855
|
-
```
|
|
1856
|
-
|
|
1857
|
-
Relation batch event for a direct root relation in `findMany` / `findManyPaginated` auto-include:
|
|
1858
|
-
|
|
1859
|
-
```json
|
|
1860
|
-
{ "type": "relationBatch", "relationPath": "profile", "values": [{ "id": "profile-1" }, null] }
|
|
1861
|
-
```
|
|
1862
|
-
|
|
1863
|
-
Nested relation batch event for a depth-2-or-deeper relation in `findMany` / `findManyPaginated` auto-include:
|
|
1864
|
-
|
|
1865
|
-
```json
|
|
1866
|
-
{
|
|
1867
|
-
"type": "nestedRelationBatch",
|
|
1868
|
-
"relationPath": "companies.users",
|
|
1869
|
-
"depth": 2,
|
|
1870
|
-
"attachments": [
|
|
1871
|
-
{ "locator": [0, "companies", 0], "value": [{ "id": "user-1" }] }
|
|
1872
|
-
]
|
|
1873
|
-
}
|
|
1874
|
-
```
|
|
1875
|
-
|
|
1876
|
-
Each `attachments[].locator` is walked from `rootArray.data` to the parent object. The leaf field to assign is the last segment of `relationPath`. For example, `relationPath: "companies.users"` and `locator: [0, "companies", 0]` means `rootArray.data[0].companies[0].users = value`.
|
|
1877
|
-
|
|
1878
|
-
Final result event:
|
|
1879
|
-
|
|
1880
|
-
```json
|
|
1881
|
-
{ "type": "result", "data": { "id": "user-id", "profile": {}, "savedJobAds": [] } }
|
|
1882
|
-
```
|
|
1883
|
-
|
|
1884
|
-
Error event:
|
|
1885
|
-
|
|
1886
|
-
```json
|
|
1887
|
-
{ "type": "error", "message": "Could not load progressive response" }
|
|
1888
|
-
```
|
|
1889
|
-
|
|
1890
|
-
For single-record progressive responses, the final `result.data` is the accumulated object built from all applied patches, unless a manual stage returns a stop result.
|
|
1891
|
-
|
|
1892
|
-
For `findMany` auto-include responses, `rootArray.data` is the source of truth for root row identity and order. Each depth-1 `relationBatch.values` array is index-aligned with `rootArray.data`, so `values[i]` belongs to `rootArray.data[i]`. Each depth-2-or-deeper `nestedRelationBatch.attachments` array carries locator/value pairs that can be applied to the accumulated root rows immediately. The terminal `result.data` is the fully merged array and can be used as a final reconcile.
|
|
1893
|
-
|
|
1894
|
-
For `findManyPaginated` auto-include responses, `pageMeta` is sent before `rootArray`. The terminal `result.data` has the normal paginated shape: `{ data, total, hasMore }`.
|
|
1895
|
-
|
|
1896
|
-
### Manual staged mode
|
|
1897
|
-
|
|
1898
|
-
Manual mode is selected when a progressive variant has a `stages` array. `mode: 'manual'` is optional.
|
|
1899
|
-
|
|
1900
|
-
Progressive config lives on an Express read operation. It is keyed by the resolved variant.
|
|
1901
|
-
|
|
1902
|
-
```ts
|
|
1903
|
-
import type { ProgressiveStage } from './generated/routeConfig.target'
|
|
1904
|
-
|
|
1905
|
-
const dashboardIdentity: ProgressiveStage<{ userId: string }> = async ({
|
|
1906
|
-
ctx,
|
|
1907
|
-
prisma,
|
|
1908
|
-
}) => {
|
|
1909
|
-
const user = await prisma.user.findFirst({
|
|
1910
|
-
select: { id: true },
|
|
1911
|
-
where: { id: ctx.userId },
|
|
1912
|
-
})
|
|
1913
|
-
|
|
1914
|
-
if (!user) {
|
|
1915
|
-
return {
|
|
1916
|
-
stop: true,
|
|
1917
|
-
data: null,
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
return {
|
|
1922
|
-
key: 'id',
|
|
1923
|
-
value: user.id,
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
const dashboardProfileBasics: ProgressiveStage<{ userId: string }> = async ({
|
|
1928
|
-
ctx,
|
|
1929
|
-
prisma,
|
|
1930
|
-
}) => {
|
|
1931
|
-
const user = await prisma.user.findFirst({
|
|
1932
|
-
select: {
|
|
1933
|
-
profile: {
|
|
1934
|
-
select: {
|
|
1935
|
-
id: true,
|
|
1936
|
-
profileName: true,
|
|
1937
|
-
profilePicture: true,
|
|
1938
|
-
jobTitle: true,
|
|
1939
|
-
location: true,
|
|
1940
|
-
skills: true,
|
|
1941
|
-
isAvailableForHire: true,
|
|
1942
|
-
},
|
|
1943
|
-
},
|
|
1944
|
-
},
|
|
1945
|
-
where: { id: ctx.userId },
|
|
1946
|
-
})
|
|
1947
|
-
|
|
1948
|
-
return {
|
|
1949
|
-
key: 'profile',
|
|
1950
|
-
value: user?.profile
|
|
1951
|
-
? {
|
|
1952
|
-
...user.profile,
|
|
1953
|
-
appliedTo: [],
|
|
1954
|
-
invitationsFor: [],
|
|
1955
|
-
}
|
|
1956
|
-
: null,
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
const dashboardApplications: ProgressiveStage<{ userId: string }> = async ({
|
|
1961
|
-
ctx,
|
|
1962
|
-
prisma,
|
|
1963
|
-
accumulated,
|
|
1964
|
-
}) => {
|
|
1965
|
-
if (accumulated.profile == null) return
|
|
1966
|
-
|
|
1967
|
-
const profile = await prisma.talentProfile.findFirst({
|
|
1968
|
-
select: {
|
|
1969
|
-
appliedTo: {
|
|
1970
|
-
orderBy: { createdAt: 'desc' },
|
|
1971
|
-
take: 50,
|
|
1972
|
-
where: { deletedAt: null },
|
|
1973
|
-
select: {
|
|
1974
|
-
id: true,
|
|
1975
|
-
createdAt: true,
|
|
1976
|
-
viewedAt: true,
|
|
1977
|
-
},
|
|
1978
|
-
},
|
|
1979
|
-
},
|
|
1980
|
-
where: { userId: ctx.userId },
|
|
1981
|
-
})
|
|
1982
|
-
|
|
1983
|
-
return {
|
|
1984
|
-
key: 'profile.appliedTo',
|
|
1985
|
-
value: profile?.appliedTo ?? [],
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
const userConfig = {
|
|
1990
|
-
resolveContext: (req) => ({
|
|
1991
|
-
userId: req.user.id,
|
|
1992
|
-
}),
|
|
1993
|
-
|
|
1994
|
-
guard: {
|
|
1995
|
-
variantHeader: 'x-api-variant',
|
|
1996
|
-
},
|
|
1997
|
-
|
|
1998
|
-
findFirst: {
|
|
1999
|
-
shape: {
|
|
2000
|
-
'/talent/dashboard': dashboardShape,
|
|
2001
|
-
me: meShape,
|
|
2002
|
-
},
|
|
2003
|
-
progressive: {
|
|
2004
|
-
'/talent/dashboard': {
|
|
2005
|
-
enabled: true,
|
|
2006
|
-
stages: [
|
|
2007
|
-
'dashboardIdentity',
|
|
2008
|
-
'dashboardProfileBasics',
|
|
2009
|
-
'dashboardApplications',
|
|
2010
|
-
],
|
|
2011
|
-
},
|
|
2012
|
-
},
|
|
2013
|
-
progressiveStages: {
|
|
2014
|
-
dashboardIdentity,
|
|
2015
|
-
dashboardProfileBasics,
|
|
2016
|
-
dashboardApplications,
|
|
2017
|
-
},
|
|
2018
|
-
},
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
app.use('/', UserRouter(userConfig))
|
|
2022
|
-
```
|
|
2023
|
-
|
|
2024
|
-
`resolveContext` is required for a manual progressive variant with `progressive.enabled !== false`. It is not required for ordinary JSON requests, auto-include mode, or single-result SSE fallback.
|
|
2025
|
-
|
|
2026
|
-
### Stage function API
|
|
2027
|
-
|
|
2028
|
-
```ts
|
|
2029
|
-
type ProgressiveStageContext<TContext = unknown, TPrisma = any> = {
|
|
2030
|
-
ctx: TContext
|
|
2031
|
-
req: Request
|
|
2032
|
-
res: Response
|
|
2033
|
-
prisma: TPrisma
|
|
2034
|
-
variant: string
|
|
2035
|
-
accumulated: Record<string, unknown>
|
|
2036
|
-
signal: AbortSignal
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
type ProgressivePatch = {
|
|
2040
|
-
key: string
|
|
2041
|
-
value: unknown
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
type ProgressiveStopResult<T = unknown> = {
|
|
2045
|
-
stop: true
|
|
2046
|
-
data: T
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
type ProgressiveStageResult<T = unknown> =
|
|
2050
|
-
| void
|
|
2051
|
-
| ProgressivePatch
|
|
2052
|
-
| ProgressivePatch[]
|
|
2053
|
-
| ProgressiveStopResult<T>
|
|
2054
|
-
|
|
2055
|
-
type ProgressiveStage<TContext = unknown, TPrisma = any, T = unknown> = (
|
|
2056
|
-
context: ProgressiveStageContext<TContext, TPrisma>,
|
|
2057
|
-
) => Promise<ProgressiveStageResult<T>>
|
|
2058
|
-
```
|
|
2059
|
-
|
|
2060
|
-
A stage may return:
|
|
2061
|
-
|
|
2062
|
-
- `void` — no patch for this stage
|
|
2063
|
-
- one `{ key, value }` patch
|
|
2064
|
-
- an array of patches
|
|
2065
|
-
- `{ stop: true, data }` to immediately send a final `result` event and stop executing later stages
|
|
2066
|
-
|
|
2067
|
-
### Patch path behavior
|
|
2068
|
-
|
|
2069
|
-
Patch keys use dot paths, for example `profile.appliedTo`.
|
|
2070
|
-
|
|
2071
|
-
Nested patches require the parent object to already exist in `accumulated`. If a stage tries to patch through `null`, `undefined`, an array, a primitive, or a non-plain object, the patch is dropped and no `field` event is sent.
|
|
2072
|
-
|
|
2073
|
-
This means parent objects should be initialized by earlier stages:
|
|
2074
|
-
|
|
2075
|
-
```ts
|
|
2076
|
-
return {
|
|
2077
|
-
key: 'profile',
|
|
2078
|
-
value: {
|
|
2079
|
-
...profileBasics,
|
|
2080
|
-
appliedTo: [],
|
|
2081
|
-
invitationsFor: [],
|
|
2082
|
-
},
|
|
2083
|
-
}
|
|
2084
|
-
```
|
|
2085
|
-
|
|
2086
|
-
Patch path segments `__proto__`, `constructor`, `prototype`, and empty path segments are rejected.
|
|
2087
|
-
|
|
2088
|
-
### Auto-include mode
|
|
2089
|
-
|
|
2090
|
-
Auto-include mode is selected with `mode: 'autoInclude'`.
|
|
2091
|
-
|
|
2092
|
-
```ts
|
|
2093
|
-
const userConfig = {
|
|
2094
|
-
guard: {
|
|
2095
|
-
variantHeader: 'x-api-variant',
|
|
2096
|
-
},
|
|
2097
|
-
|
|
2098
|
-
findUnique: {
|
|
2099
|
-
progressive: {
|
|
2100
|
-
detail: {
|
|
2101
|
-
enabled: true,
|
|
2102
|
-
mode: 'autoInclude',
|
|
2103
|
-
fallback: 'singleResult',
|
|
2104
|
-
},
|
|
2105
|
-
},
|
|
2106
|
-
},
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
app.use('/', UserRouter(userConfig))
|
|
2110
|
-
```
|
|
2111
|
-
|
|
2112
|
-
Client request:
|
|
2113
|
-
|
|
2114
|
-
```ts
|
|
2115
|
-
import { encodeQueryParams } from './generated/client/encodeQueryParams'
|
|
2116
|
-
|
|
2117
|
-
const params = encodeQueryParams({
|
|
2118
|
-
where: { id: 'user-id' },
|
|
2119
|
-
include: {
|
|
2120
|
-
profile: {
|
|
2121
|
-
select: {
|
|
2122
|
-
id: true,
|
|
2123
|
-
displayName: true,
|
|
2124
|
-
},
|
|
2125
|
-
},
|
|
2126
|
-
posts: {
|
|
2127
|
-
orderBy: { createdAt: 'desc' },
|
|
2128
|
-
take: 10,
|
|
2129
|
-
select: {
|
|
2130
|
-
id: true,
|
|
2131
|
-
title: true,
|
|
2132
|
-
},
|
|
2133
|
-
},
|
|
2134
|
-
},
|
|
2135
|
-
})
|
|
2136
|
-
|
|
2137
|
-
const response = await fetch(`/user/unique?${params}`, {
|
|
2138
|
-
headers: {
|
|
2139
|
-
Accept: 'text/event-stream',
|
|
2140
|
-
'x-api-variant': 'detail',
|
|
2141
|
-
},
|
|
2142
|
-
})
|
|
2143
|
-
```
|
|
2144
|
-
|
|
2145
|
-
On single-record reads, auto-include sends root scalar fields first, then sends relation field events as separate relation queries finish:
|
|
2146
|
-
|
|
2147
|
-
```json
|
|
2148
|
-
{ "type": "field", "key": "id", "value": "user-id" }
|
|
2149
|
-
```
|
|
2150
|
-
|
|
2151
|
-
```json
|
|
2152
|
-
{ "type": "field", "key": "profile", "value": { "id": "profile-id", "displayName": "Alice" } }
|
|
2153
|
-
```
|
|
2154
|
-
|
|
2155
|
-
```json
|
|
2156
|
-
{ "type": "field", "key": "posts", "value": [{ "id": "post-id", "title": "Hello" }] }
|
|
2157
|
-
```
|
|
2158
|
-
|
|
2159
|
-
The final `result` event contains the assembled object.
|
|
2160
|
-
|
|
2161
|
-
For `findMany`, auto-include sends the root rows first, then sends one relation batch event for each supported direct root relation stage. Depth-2-or-deeper stages send `nestedRelationBatch` events with locators pointing to the parent object inside the accumulated root rows:
|
|
2162
|
-
|
|
2163
|
-
```ts
|
|
2164
|
-
const listConfig = {
|
|
2165
|
-
guard: {
|
|
2166
|
-
variantHeader: 'x-api-variant',
|
|
2167
|
-
},
|
|
2168
|
-
|
|
2169
|
-
findMany: {
|
|
2170
|
-
progressive: {
|
|
2171
|
-
list: {
|
|
2172
|
-
enabled: true,
|
|
2173
|
-
mode: 'autoInclude',
|
|
2174
|
-
fallback: 'singleResult',
|
|
2175
|
-
},
|
|
2176
|
-
},
|
|
2177
|
-
},
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
const params = encodeQueryParams({
|
|
2181
|
-
where: { isActive: true },
|
|
2182
|
-
take: 50,
|
|
2183
|
-
include: {
|
|
2184
|
-
profile: {
|
|
2185
|
-
select: {
|
|
2186
|
-
id: true,
|
|
2187
|
-
displayName: true,
|
|
2188
|
-
},
|
|
2189
|
-
},
|
|
2190
|
-
},
|
|
2191
|
-
})
|
|
2192
|
-
|
|
2193
|
-
const response = await fetch(`/user?${params}`, {
|
|
2194
|
-
headers: {
|
|
2195
|
-
Accept: 'text/event-stream',
|
|
2196
|
-
'x-api-variant': 'list',
|
|
2197
|
-
},
|
|
2198
|
-
})
|
|
2199
|
-
```
|
|
2200
|
-
|
|
2201
|
-
Example shallow `findMany` auto-include event sequence:
|
|
2202
|
-
|
|
2203
|
-
```json
|
|
2204
|
-
{ "type": "rootArray", "data": [{ "id": "user-1" }, { "id": "user-2" }] }
|
|
2205
|
-
```
|
|
2206
|
-
|
|
2207
|
-
```json
|
|
2208
|
-
{ "type": "relationBatch", "relationPath": "profile", "values": [{ "id": "profile-1", "displayName": "Alice" }, null] }
|
|
2209
|
-
```
|
|
2210
|
-
|
|
2211
|
-
```json
|
|
2212
|
-
{ "type": "result", "data": [{ "id": "user-1", "profile": { "id": "profile-1", "displayName": "Alice" } }, { "id": "user-2", "profile": null }] }
|
|
2213
|
-
```
|
|
2214
|
-
|
|
2215
|
-
Deep `findMany` and `findManyPaginated` requests use the same planner. Nested stages are loaded by flattening the parent rows at each path, then emitted as locator-based attachment batches:
|
|
2216
|
-
|
|
2217
|
-
```ts
|
|
2218
|
-
const params = encodeQueryParams({
|
|
2219
|
-
take: 20,
|
|
2220
|
-
include: {
|
|
2221
|
-
companies: {
|
|
2222
|
-
include: {
|
|
2223
|
-
users: {
|
|
2224
|
-
include: {
|
|
2225
|
-
profile: {
|
|
2226
|
-
select: {
|
|
2227
|
-
id: true,
|
|
2228
|
-
displayName: true,
|
|
2229
|
-
},
|
|
2230
|
-
},
|
|
2231
|
-
},
|
|
2232
|
-
},
|
|
2233
|
-
},
|
|
2234
|
-
},
|
|
2235
|
-
},
|
|
2236
|
-
})
|
|
2237
|
-
|
|
2238
|
-
const response = await fetch(`/organization/paginated?${params}`, {
|
|
2239
|
-
headers: {
|
|
2240
|
-
Accept: 'text/event-stream',
|
|
2241
|
-
'x-api-variant': 'list',
|
|
2242
|
-
},
|
|
2243
|
-
})
|
|
2244
|
-
```
|
|
2245
|
-
|
|
2246
|
-
Example deep event sequence:
|
|
2247
|
-
|
|
2248
|
-
```json
|
|
2249
|
-
{ "type": "pageMeta", "total": 120, "hasMore": true }
|
|
2250
|
-
```
|
|
2251
|
-
|
|
2252
|
-
```json
|
|
2253
|
-
{ "type": "rootArray", "data": [{ "id": "org-1" }, { "id": "org-2" }] }
|
|
2254
|
-
```
|
|
2255
|
-
|
|
2256
|
-
```json
|
|
2257
|
-
{ "type": "relationBatch", "relationPath": "companies", "values": [[{ "id": "company-1" }], []] }
|
|
2258
|
-
```
|
|
2259
|
-
|
|
2260
|
-
```json
|
|
2261
|
-
{
|
|
2262
|
-
"type": "nestedRelationBatch",
|
|
2263
|
-
"relationPath": "companies.users",
|
|
2264
|
-
"depth": 2,
|
|
2265
|
-
"attachments": [
|
|
2266
|
-
{ "locator": [0, "companies", 0], "value": [{ "id": "user-1" }] }
|
|
2267
|
-
]
|
|
2268
|
-
}
|
|
2269
|
-
```
|
|
2270
|
-
|
|
2271
|
-
```json
|
|
2272
|
-
{
|
|
2273
|
-
"type": "nestedRelationBatch",
|
|
2274
|
-
"relationPath": "companies.users.profile",
|
|
2275
|
-
"depth": 3,
|
|
2276
|
-
"attachments": [
|
|
2277
|
-
{ "locator": [0, "companies", 0, "users", 0], "value": { "id": "profile-1", "displayName": "Alice" } }
|
|
2278
|
-
]
|
|
2279
|
-
}
|
|
2280
|
-
```
|
|
2281
|
-
|
|
2282
|
-
```json
|
|
2283
|
-
{ "type": "result", "data": { "data": [{ "id": "org-1", "companies": [{ "id": "company-1", "users": [{ "id": "user-1", "profile": { "id": "profile-1", "displayName": "Alice" } }] }] }, { "id": "org-2", "companies": [] }], "total": 120, "hasMore": true } }
|
|
2284
|
-
```
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
### Guarded auto-include mode
|
|
2289
|
-
|
|
2290
|
-
Auto-include also works with prisma-guard on supported single-record Express reads.
|
|
2291
|
-
|
|
2292
|
-
When a route operation has both `progressive: { mode: 'autoInclude' }` and a guard `shape`, the router resolves the shape through prisma-guard before planning the stream. The request should not send `select`, `include`, or `omit`; the guard shape's default read projection is used as the response contract.
|
|
2293
|
-
|
|
2294
|
-
```ts
|
|
2295
|
-
import { force } from 'prisma-guard'
|
|
2296
|
-
|
|
2297
|
-
const userConfig = {
|
|
2298
|
-
guard: {
|
|
2299
|
-
variantHeader: 'x-api-variant',
|
|
2300
|
-
},
|
|
2301
|
-
|
|
2302
|
-
findFirst: {
|
|
2303
|
-
shape: {
|
|
2304
|
-
me: (ctx) => ({
|
|
2305
|
-
where: {
|
|
2306
|
-
id: { equals: force(ctx.userId) },
|
|
2307
|
-
},
|
|
2308
|
-
select: {
|
|
2309
|
-
id: true,
|
|
2310
|
-
email: true,
|
|
2311
|
-
profile: {
|
|
2312
|
-
select: {
|
|
2313
|
-
id: true,
|
|
2314
|
-
displayName: true,
|
|
2315
|
-
},
|
|
2316
|
-
},
|
|
2317
|
-
companies: {
|
|
2318
|
-
where: {
|
|
2319
|
-
company: {
|
|
2320
|
-
is: {
|
|
2321
|
-
deletedAt: { equals: null },
|
|
2322
|
-
},
|
|
2323
|
-
},
|
|
2324
|
-
},
|
|
2325
|
-
select: {
|
|
2326
|
-
company: {
|
|
2327
|
-
select: {
|
|
2328
|
-
id: true,
|
|
2329
|
-
name: true,
|
|
2330
|
-
},
|
|
2331
|
-
},
|
|
2332
|
-
role: {
|
|
2333
|
-
select: {
|
|
2334
|
-
role: true,
|
|
2335
|
-
},
|
|
2336
|
-
},
|
|
2337
|
-
},
|
|
2338
|
-
},
|
|
2339
|
-
},
|
|
2340
|
-
}),
|
|
2341
|
-
},
|
|
2342
|
-
progressive: {
|
|
2343
|
-
me: {
|
|
2344
|
-
enabled: true,
|
|
2345
|
-
mode: 'autoInclude',
|
|
2346
|
-
fallback: 'error',
|
|
2347
|
-
},
|
|
2348
|
-
},
|
|
2349
|
-
},
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
app.use('/', UserRouter(userConfig))
|
|
2353
|
-
```
|
|
2354
|
-
|
|
2355
|
-
Client request:
|
|
2356
|
-
|
|
2357
|
-
```http
|
|
2358
|
-
GET /user/first
|
|
2359
|
-
Accept: text/event-stream
|
|
2360
|
-
x-api-variant: me
|
|
2361
|
-
```
|
|
2362
|
-
|
|
2363
|
-
The guarded auto-include planner resolves the matched shape, applies the shape's default projection, strips direct relation branches from the root query, and loads those direct relations as follow-up guarded stage queries. The root query and every stage query execute through `prisma.model.guard(slicedShape, caller).method(args)`. Relation stages are never executed as raw Prisma delegate calls.
|
|
2364
|
-
|
|
2365
|
-
Direct relation stages stream as normal `field` events:
|
|
2366
|
-
|
|
2367
|
-
```json
|
|
2368
|
-
{ "type": "field", "key": "id", "value": "user-id" }
|
|
2369
|
-
```
|
|
2370
|
-
|
|
2371
|
-
```json
|
|
2372
|
-
{ "type": "field", "key": "profile", "value": { "id": "profile-id", "displayName": "Alice" } }
|
|
2373
|
-
```
|
|
2374
|
-
|
|
2375
|
-
```json
|
|
2376
|
-
{ "type": "field", "key": "companies", "value": [{ "company": { "id": "company-id", "name": "Acme" }, "role": { "role": "admin" } }] }
|
|
2377
|
-
```
|
|
2378
|
-
|
|
2379
|
-
The final `result` event contains the assembled object.
|
|
2380
|
-
|
|
2381
|
-
For guarded auto-include, `fallback: 'error'` is recommended during development so unsupported shapes are visible immediately. In production, `fallback: 'singleResult'` can be used to return one final SSE result through the normal guarded handler when the planner cannot split the shape.
|
|
2382
|
-
|
|
2383
|
-
### Auto-include behavior and limits
|
|
2384
|
-
|
|
2385
|
-
Auto-include is designed for supported Prisma `include` and relation `select` trees on reads.
|
|
2386
|
-
|
|
2387
|
-
When `findManyPaginatedMode = "transaction"`, the root `findMany` and the total count run inside one interactive transaction, so `data` and `total` are mutually consistent. Relation stages, however, load **after** the transaction commits and are not part of it — relation batches can reflect writes committed between the root transaction and the stage queries.
|
|
2388
|
-
|
|
2389
|
-
Supported unguarded root operations:
|
|
2390
|
-
|
|
2391
|
-
- `findUnique`
|
|
2392
|
-
- `findUniqueOrThrow`
|
|
2393
|
-
- `findFirst`
|
|
2394
|
-
- `findFirstOrThrow`
|
|
2395
|
-
- `findMany`
|
|
2396
|
-
- `findManyPaginated`
|
|
2397
|
-
|
|
2398
|
-
Supported guarded root operations:
|
|
2399
|
-
|
|
2400
|
-
- `findUnique`
|
|
2401
|
-
- `findUniqueOrThrow`
|
|
2402
|
-
- `findFirst`
|
|
2403
|
-
- `findFirstOrThrow`
|
|
2404
|
-
|
|
2405
|
-
Supported unguarded single-record relation shapes:
|
|
2406
|
-
|
|
2407
|
-
- direct to-one relation includes/selects
|
|
2408
|
-
- direct to-many relation includes/selects
|
|
2409
|
-
- to-many relation args such as `where`, `orderBy`, `take`, `skip`, `cursor`, and `distinct`
|
|
2410
|
-
- nested relation loading through to-one parents
|
|
2411
|
-
|
|
2412
|
-
Unguarded single-record auto-include falls back when a nested stage crosses a to-many parent. Direct to-many loading is still supported, but nested loading under that array is not handled by the single-record progressive runtime.
|
|
2413
|
-
|
|
2414
|
-
Supported unguarded `findMany` and `findManyPaginated` relation shapes:
|
|
2415
|
-
|
|
2416
|
-
- direct and nested to-one relation includes/selects
|
|
2417
|
-
- direct and nested to-many relation includes/selects
|
|
2418
|
-
- relation-level `where` and `orderBy`
|
|
2419
|
-
- single-column link fields only
|
|
2420
|
-
- nested depth up to the configured planner limit
|
|
2421
|
-
|
|
2422
|
-
For unguarded `findMany` and `findManyPaginated`, each stage loads children with a batched query over the flattened parent rows at that stage's `parentPath`. Direct root stages stream `relationBatch` events. Depth-2-or-deeper stages stream `nestedRelationBatch` events with locator/value attachments, then also appear in the terminal `result` event.
|
|
2423
|
-
|
|
2424
|
-
`findMany` and `findManyPaginated` auto-include apply configured pagination limits to the root query before loading relation batches. If the client omits `take`, `pagination.defaultLimit` is applied when configured. If the client sends a large `take`, `pagination.maxLimit` is enforced before the root query runs.
|
|
2425
|
-
|
|
2426
|
-
Supported guarded single-record relation shapes:
|
|
2427
|
-
|
|
2428
|
-
- direct to-one relation `select` branches
|
|
2429
|
-
- direct to-many relation `select` branches
|
|
2430
|
-
- relation-level `where`, `orderBy`, `take`, `skip`, `cursor`, and `distinct` on direct to-many stages
|
|
2431
|
-
- nested `select` trees inside a direct relation stage, loaded as part of that guarded stage query
|
|
2432
|
-
|
|
2433
|
-
Guarded auto-include plans from the guard shape's default projection. Client-provided `select`, `include`, or `omit` is rejected for guarded auto-include and follows the configured fallback behavior. Normal guarded JSON reads still support client projection validation according to prisma-guard rules.
|
|
2434
|
-
|
|
2435
|
-
Guarded auto-include falls back when a shape uses an unsupported streaming form, including:
|
|
2436
|
-
|
|
2437
|
-
- client-provided `select`, `include`, or `omit`
|
|
2438
|
-
- shape-level `include` or `omit` in the auto-include plan
|
|
2439
|
-
- `_count` anywhere in the planned projection
|
|
2440
|
-
- implicit many-to-many relations
|
|
2441
|
-
- composite relation link fields
|
|
2442
|
-
- root relation filters in `where`, `orderBy`, or `cursor`
|
|
2443
|
-
- relation references in a staged relation's `orderBy` or `cursor`
|
|
2444
|
-
- link-field collisions in a staged relation `where`
|
|
2445
|
-
- planner limits for maximum stage count
|
|
2446
|
-
- guarded `findMany` or `findManyPaginated` auto-include
|
|
2447
|
-
|
|
2448
|
-
Unguarded auto-include falls back when a request uses an unsupported streaming form, including:
|
|
2449
|
-
|
|
2450
|
-
- `_count` in `select` or `include`
|
|
2451
|
-
- implicit many-to-many relations
|
|
2452
|
-
- `select` and `include` at the same level
|
|
2453
|
-
- `select` and `omit` at the same level
|
|
2454
|
-
- relation filters/order/cursor in the root query
|
|
2455
|
-
- relation filters/order/cursor inside staged relation queries when unsupported
|
|
2456
|
-
- omitted required link fields needed to stitch parent and child records
|
|
2457
|
-
- planner limits for maximum depth or stage count
|
|
2458
|
-
- single-record nested relation loading through a to-many parent
|
|
2459
|
-
- `findMany` / `findManyPaginated` relation stages with composite link fields
|
|
2460
|
-
- `findMany` / `findManyPaginated` to-many relation stages using `take`, `skip`, `cursor`, or `distinct`
|
|
2461
|
-
- `createManyAndReturn` and `updateManyAndReturn`
|
|
2462
|
-
|
|
2463
|
-
When fallback happens:
|
|
2464
|
-
|
|
2465
|
-
- `fallback: 'singleResult'` runs the normal Prisma read and returns one SSE `result` event
|
|
2466
|
-
- `fallback: 'error'` sends an SSE `error` event instead
|
|
2467
|
-
|
|
2468
|
-
If `fallback` is omitted, the default behavior is equivalent to `'singleResult'`.
|
|
2469
|
-
|
|
2470
|
-
`findMany` and `findManyPaginated` auto-include use a batched relation loading strategy. They do not preserve Prisma's per-parent semantics for to-many `take`, `skip`, `cursor`, or `distinct`, so those cases fall back instead of returning silently different data.
|
|
2471
|
-
|
|
2472
|
-
Auto-include does not require `resolveContext` or `progressiveStages`.
|
|
2473
|
-
|
|
2474
|
-
### Hooks and guard behavior
|
|
2475
|
-
|
|
2476
|
-
For SSE requests, routing and before-hooks run in the same order as JSON requests:
|
|
2477
|
-
|
|
2478
|
-
```text
|
|
2479
|
-
operation before-hooks
|
|
2480
|
-
variant before-hooks
|
|
2481
|
-
progressive SSE middleware
|
|
2482
|
-
```
|
|
2483
|
-
|
|
2484
|
-
If the progressive middleware starts or completes an SSE response, it does not continue to the normal generated handler. Consequently, variant after-hooks and operation after-hooks do not run for that request. They are not cleanup handlers.
|
|
2485
|
-
|
|
2486
|
-
Additional behavior:
|
|
2487
|
-
|
|
2488
|
-
- manual progressive stages receive `req.prisma` directly
|
|
2489
|
-
- manual progressive stages do not automatically use guard shapes
|
|
2490
|
-
- auto-include mode does not run when an operation has a guard shape; it falls back to single-result SSE or emits an SSE error depending on `fallback`
|
|
2491
|
-
- variants without progressive config use the single-result SSE fallback through the normal generated core read handler, so guard shape behavior matches the JSON endpoint
|
|
2492
|
-
|
|
2493
|
-
Progressive lookup uses the declared matched variant key. A caller such as `/user/123` matched by `/user/:id` should configure `progressive['/user/:id']`. If only a legacy concrete raw-caller key exists, the router may use it as a compatibility fallback and emits a deduplicated deprecation warning.
|
|
2494
|
-
|
|
2495
|
-
Manual stage authors are responsible for using the resolved context and enforcing ownership or tenant constraints in their stage queries.
|
|
2496
|
-
|
|
2497
|
-
### Client-side usage
|
|
2498
|
-
|
|
2499
|
-
Use `fetch` with streaming. Native browser `EventSource` cannot send custom headers like `x-api-variant`.
|
|
2500
|
-
|
|
2501
|
-
Minimal example:
|
|
2502
|
-
|
|
2503
|
-
```ts
|
|
2504
|
-
const response = await fetch('/user/first', {
|
|
2505
|
-
headers: {
|
|
2506
|
-
Accept: 'text/event-stream',
|
|
2507
|
-
'x-api-variant': '/talent/dashboard',
|
|
2508
|
-
},
|
|
2509
|
-
})
|
|
2510
|
-
|
|
2511
|
-
if (!response.body) {
|
|
2512
|
-
throw new Error('ReadableStream is not available')
|
|
2513
|
-
}
|
|
2514
|
-
|
|
2515
|
-
const reader = response.body.getReader()
|
|
2516
|
-
const decoder = new TextDecoder()
|
|
2517
|
-
let buffer = ''
|
|
2518
|
-
let rows: Array<Record<string, unknown>> = []
|
|
2519
|
-
let fields: Record<string, unknown> = {}
|
|
2520
|
-
let data: unknown = undefined
|
|
2521
|
-
|
|
2522
|
-
const lastSegment = (path: string) => {
|
|
2523
|
-
const parts = path.split('.')
|
|
2524
|
-
return parts[parts.length - 1] ?? path
|
|
2525
|
-
}
|
|
2526
|
-
|
|
2527
|
-
const walk = (source: Array<Record<string, unknown>>, locator: Array<number | string>) => {
|
|
2528
|
-
let cursor: unknown = source[locator[0] as number]
|
|
2529
|
-
for (let i = 1; i < locator.length; i++) {
|
|
2530
|
-
if (cursor == null) return null
|
|
2531
|
-
cursor = (cursor as Record<string | number, unknown>)[locator[i]]
|
|
2532
|
-
}
|
|
2533
|
-
return cursor
|
|
2534
|
-
}
|
|
2535
|
-
|
|
2536
|
-
while (true) {
|
|
2537
|
-
const { value, done } = await reader.read()
|
|
2538
|
-
if (done) break
|
|
2539
|
-
|
|
2540
|
-
buffer += decoder.decode(value, { stream: true })
|
|
2541
|
-
const parts = buffer.split('\n\n')
|
|
2542
|
-
buffer = parts.pop() ?? ''
|
|
2543
|
-
|
|
2544
|
-
for (const part of parts) {
|
|
2545
|
-
const line = part
|
|
2546
|
-
.split('\n')
|
|
2547
|
-
.find((entry) => entry.startsWith('data: '))
|
|
2548
|
-
|
|
2549
|
-
if (!line) continue
|
|
2550
|
-
|
|
2551
|
-
const event = JSON.parse(line.slice('data: '.length))
|
|
2552
|
-
|
|
2553
|
-
if (event.type === 'rootArray') {
|
|
2554
|
-
rows = event.data
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
|
-
if (event.type === 'field') {
|
|
2558
|
-
fields[event.key] = event.value
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
if (event.type === 'relationBatch') {
|
|
2562
|
-
const field = lastSegment(event.relationPath)
|
|
2563
|
-
rows = rows.map((row, index) => ({
|
|
2564
|
-
...row,
|
|
2565
|
-
[field]: event.values[index],
|
|
2566
|
-
}))
|
|
2567
|
-
}
|
|
2568
|
-
|
|
2569
|
-
if (event.type === 'nestedRelationBatch') {
|
|
2570
|
-
const field = lastSegment(event.relationPath)
|
|
2571
|
-
for (const attachment of event.attachments) {
|
|
2572
|
-
const parent = walk(rows, attachment.locator)
|
|
2573
|
-
if (parent && typeof parent === 'object' && !Array.isArray(parent)) {
|
|
2574
|
-
const record = parent as Record<string, unknown>
|
|
2575
|
-
record[field] = attachment.value
|
|
2576
|
-
}
|
|
2577
|
-
}
|
|
2578
|
-
}
|
|
2579
|
-
|
|
2580
|
-
if (event.type === 'result') {
|
|
2581
|
-
data = event.data
|
|
2582
|
-
}
|
|
2583
|
-
}
|
|
2584
|
-
}
|
|
2585
|
-
```
|
|
2586
|
-
|
|
2587
|
-
For React Query, include the variant and mode in the query key:
|
|
2588
|
-
|
|
2589
|
-
```ts
|
|
2590
|
-
['user', 'first', { variant: '/talent/dashboard', mode: 'sse' }]
|
|
2591
|
-
```
|
|
2592
|
-
|
|
2593
|
-
Do not reuse the same query key as the JSON endpoint because the same URL can return different shapes depending on `x-api-variant`.
|
|
2594
|
-
|
|
2595
|
-
For deep `findMany` / `findManyPaginated` auto-include, apply `nestedRelationBatch` events for progressive rendering and still treat the final `result` event as the authoritative nested payload for reconciliation. Direct root relation stages emit `relationBatch`; depth-2-or-deeper relation stages emit `nestedRelationBatch`.
|
|
2596
|
-
|
|
2597
|
-
### Runtime notes
|
|
2598
|
-
|
|
2599
|
-
The SSE response sets:
|
|
2600
|
-
|
|
2601
|
-
```http
|
|
2602
|
-
Content-Type: text/event-stream
|
|
2603
|
-
Cache-Control: no-cache, no-transform
|
|
2604
|
-
Connection: keep-alive
|
|
2605
|
-
X-Accel-Buffering: no
|
|
2606
|
-
```
|
|
2607
|
-
|
|
2608
|
-
The server sends keepalive comments periodically:
|
|
2609
|
-
|
|
2610
|
-
```txt
|
|
2611
|
-
: keepalive
|
|
2612
|
-
```
|
|
2613
|
-
|
|
2614
|
-
If compression middleware is used, configure it to skip `text/event-stream`, or ensure `res.flush()` is available so events are flushed promptly.
|
|
2615
|
-
|
|
2616
|
-
## updateEach (Express, Fastify, Hono, internal batch)
|
|
2617
|
-
|
|
2618
|
-
`updateEach` applies many independent per-row updates in one request. Each item is passed to Prisma `update`.
|
|
2619
|
-
|
|
2620
|
-
```http
|
|
2621
|
-
POST /{modelname}/each
|
|
2622
|
-
```
|
|
2623
|
-
|
|
2624
|
-
Request body is a JSON array of Prisma `update` args. Each item should contain `{ where, data }`:
|
|
2625
|
-
|
|
2626
|
-
```json
|
|
2627
|
-
[
|
|
2628
|
-
{ "where": { "id": "a" }, "data": { "status": "done" } },
|
|
2629
|
-
{ "where": { "id": "b" }, "data": { "status": "failed" } }
|
|
2630
|
-
]
|
|
2631
|
-
```
|
|
2632
|
-
|
|
2633
|
-
By default each row runs independently and the response is a per-row result array:
|
|
2634
|
-
|
|
2635
|
-
```json
|
|
2636
|
-
[
|
|
2637
|
-
{ "status": "ok", "data": { "id": "a", "status": "done" } },
|
|
2638
|
-
{ "status": "error", "error": "Record not found" }
|
|
2639
|
-
]
|
|
2640
|
-
```
|
|
2641
|
-
|
|
2642
|
-
Send header `x-batch-atomic: true` to run all rows inside a single interactive transaction instead. In atomic mode any failing row rolls back the whole batch and the request errors; the endpoint requires a Prisma client with transaction support.
|
|
2643
|
-
|
|
2644
|
-
Batch size is capped to protect the database connection pool:
|
|
2645
|
-
|
|
2646
|
-
| Mode | Maximum items | Execution |
|
|
2647
|
-
| ---- | ------------- | --------- |
|
|
2648
|
-
| Non-atomic | 1000 | Bounded worker pool |
|
|
2649
|
-
| Atomic | 100 | Sequential updates inside one transaction |
|
|
2650
|
-
|
|
2651
|
-
Requests above the limit return 400.
|
|
2652
|
-
|
|
2653
|
-
### Enabling
|
|
2654
|
-
|
|
2655
|
-
`updateEach` is **opt-in only** on Express, Fastify, and Hono. It is **not** enabled by `enableAll: true`. Add it explicitly:
|
|
2656
|
-
|
|
2657
|
-
```ts
|
|
2658
|
-
app.use('/', UserRouter({
|
|
2659
|
-
updateEach: {
|
|
2660
|
-
before: [requireInternalAuth],
|
|
2661
|
-
},
|
|
2662
|
-
}))
|
|
2663
|
-
```
|
|
2664
|
-
|
|
2665
|
-
Only operation-wide `before` and `after` hooks are configurable. It has no `shape`, `variants`, pagination, or progressive config. In development, enabling `updateEach` without a `before` hook may print a warning because this route bypasses guard shapes and should be protected explicitly.
|
|
2666
|
-
|
|
2667
|
-
When enabled, `updateEach` is included in generated OpenAPI output as `POST /{modelname}/each`. It remains excluded from `enableAll: true`.
|
|
2668
|
-
|
|
2669
|
-
### Guard and security
|
|
2670
|
-
|
|
2671
|
-
`updateEach` does **not** apply prisma-guard shapes on any target, by design. It is intended as a trusted internal batch path, for example worker-to-backend bulk updates, not a public endpoint. Because it bypasses guard, it can write any field the underlying `update` allows.
|
|
2672
|
-
|
|
2673
|
-
Raw caller resolution still runs before hooks on all three targets, so a `before` hook can read the caller for its own authorization logic. No variant key is selected and no variant hook gate runs for `updateEach`. This is separate from guard shapes and does not enable guard enforcement.
|
|
2674
|
-
|
|
2675
|
-
Protect it yourself with route middleware (`before`) enforcing authentication or network-level restrictions. A guard `variantHeader` such as `x-api-variant` is **not** a security boundary — it only selects a caller value for hooks to read and is trivially spoofable. Do not expose `/each` to untrusted callers.
|
|
2676
|
-
|
|
2677
|
-
## Response shaping: select, include, omit
|
|
2678
|
-
|
|
2679
|
-
Read and single-record write operations support three response shaping parameters:
|
|
2680
|
-
|
|
2681
|
-
- **`select`** — choose which fields to include. Set scalar fields to `true`, use nested objects for relations.
|
|
2682
|
-
- **`include`** — include relations in addition to all scalar fields. Use nested `include`/`select` for deep loading.
|
|
2683
|
-
- **`omit`** — exclude specific scalar fields from the response.
|
|
2684
|
-
|
|
2685
|
-
`select` and `include` cannot be used together at the same level. `select` and `omit` cannot be used together at the same level. `omit` can be combined with `include`.
|
|
2686
|
-
|
|
2687
|
-
The `omit` parameter requires Prisma 6.2.0+. On versions 6.0.x–6.1.x, requests using `omit` return 400.
|
|
2688
|
-
|
|
2689
|
-
When using guard shapes, the shape's `select` or `include` defines both the whitelist and the default projection for read operations. See [Select and include in shapes](#select-and-include-in-shapes).
|
|
2690
|
-
|
|
2691
|
-
## BigInt and Decimal handling
|
|
2692
|
-
|
|
2693
|
-
BigInt and Decimal values are serialized as strings in JSON responses. Buffer and Uint8Array values are serialized as base64 strings. The OpenAPI spec documents BigInt and Decimal fields as `type: string`.
|
|
2694
|
-
|
|
2695
|
-
On the client side, `encodeQueryParams` handles BigInt serialization automatically.
|
|
2696
|
-
|
|
2697
|
-
## Pagination
|
|
2698
|
-
|
|
2699
|
-
`findManyPaginated` returns `{ data, total, hasMore }`. Execution is controlled by the schema-wide `findManyPaginatedMode` generator option. The default is `"promiseAll"`, which runs `findMany` and `count` concurrently with `Promise.all`. This is faster but not atomic under concurrent writes. `"transaction"` runs both queries inside an interactive transaction and returns `500` if transaction support is missing.
|
|
2700
|
-
|
|
2701
|
-
The `hasMore` field is reliable for forward offset pagination (`skip` + positive `take`) only. When `take` is `0`, `hasMore` is `false`. When using cursor-based pagination or negative `take` (backward pagination), `hasMore` may be inaccurate.
|
|
2702
|
-
|
|
2703
|
-
When `distinct` is used with `findManyPaginated`, the total count is determined by executing a distinct query up to the configured limit (default: 100,000 rows). If the number of distinct values exceeds this limit, the total falls back to an approximate non-distinct count. When a guard shape is configured together with `distinct`, the total falls back to a guarded non-distinct count so the internal count query does not need to reuse the public read projection.
|
|
2704
|
-
|
|
2705
|
-
Configure default and maximum page sizes and the distinct count limit:
|
|
2706
|
-
|
|
2707
|
-
```ts
|
|
2708
|
-
UserRouter({
|
|
2709
|
-
findManyPaginated: {},
|
|
2710
|
-
pagination: {
|
|
2711
|
-
defaultLimit: 20,
|
|
2712
|
-
maxLimit: 100,
|
|
2713
|
-
distinctCountLimit: 50000,
|
|
2714
|
-
},
|
|
2715
|
-
})
|
|
2716
|
-
```
|
|
2717
|
-
|
|
2718
|
-
`pagination.defaultLimit` is applied when the client omits `take`. It is not applied when a guard shape controls pagination. `pagination.maxLimit` caps `take` by absolute value even when a guard shape is present. `pagination.distinctCountLimit` overrides the default 100,000 row threshold for distinct count estimation. All settings apply to `findMany` and `findManyPaginated`.
|
|
2719
|
-
|
|
2720
|
-
### Materialized count source
|
|
2721
|
-
|
|
2722
|
-
`findManyPaginated` can read `total` from a materialized view instead of calling Prisma `count`. This is useful for large static or periodically refreshed datasets where a precomputed count is cheaper than a live count.
|
|
2723
|
-
|
|
2724
|
-
Configure it globally for the router:
|
|
2725
|
-
|
|
2726
|
-
```ts
|
|
2727
|
-
UserRouter({
|
|
2728
|
-
findManyPaginated: {},
|
|
2729
|
-
pagination: {
|
|
2730
|
-
countSource: {
|
|
2731
|
-
type: 'materializedView',
|
|
2732
|
-
schema: 'public',
|
|
2733
|
-
relation: 'mv_user_count',
|
|
2734
|
-
column: 'total',
|
|
2735
|
-
},
|
|
2736
|
-
},
|
|
2737
|
-
})
|
|
2738
|
-
```
|
|
2739
|
-
|
|
2740
|
-
Or override it per endpoint:
|
|
2741
|
-
|
|
2742
|
-
```ts
|
|
2743
|
-
UserRouter({
|
|
2744
|
-
pagination: {
|
|
2745
|
-
defaultLimit: 20,
|
|
2746
|
-
maxLimit: 100,
|
|
2747
|
-
},
|
|
2748
|
-
findManyPaginated: {
|
|
2749
|
-
pagination: {
|
|
2750
|
-
countSource: {
|
|
2751
|
-
type: 'materializedView',
|
|
2752
|
-
schema: 'public',
|
|
2753
|
-
relation: 'mv_active_user_count',
|
|
2754
|
-
column: 'total',
|
|
2755
|
-
},
|
|
2756
|
-
},
|
|
2757
|
-
},
|
|
2758
|
-
})
|
|
2759
|
-
```
|
|
2760
|
-
|
|
2761
|
-
The materialized-view count source is used only when the request has no dynamic `where`, no `distinct`, and no guard shape. If any of those are present, the handler falls back to the normal delegate count so `total` stays consistent with the filtered data.
|
|
2762
|
-
|
|
2763
|
-
The materialized count query uses PostgreSQL-style `$N` placeholders and `LIMIT 1`, so it is intended for PostgreSQL and CockroachDB-style clients. The optional `countSource.where` supports flat equality and `null` only. Operators, arrays, and nested objects are rejected at router construction.
|
|
2764
|
-
|
|
2765
|
-
Example with a static filter on the count view:
|
|
2766
|
-
|
|
2767
|
-
```ts
|
|
2768
|
-
UserRouter({
|
|
2769
|
-
findManyPaginated: {
|
|
2770
|
-
pagination: {
|
|
2771
|
-
countSource: {
|
|
2772
|
-
type: 'materializedView',
|
|
2773
|
-
relation: 'mv_user_count_by_status',
|
|
2774
|
-
column: 'total',
|
|
2775
|
-
where: { status: 'active' },
|
|
2776
|
-
},
|
|
2777
|
-
},
|
|
2778
|
-
},
|
|
2779
|
-
})
|
|
2780
|
-
```
|
|
2781
|
-
|
|
2782
|
-
## Error handling
|
|
2783
|
-
|
|
2784
|
-
All errors are returned as JSON with a `message` field:
|
|
2785
|
-
|
|
2786
|
-
```json
|
|
2787
|
-
{ "message": "Unique constraint violation" }
|
|
2788
|
-
```
|
|
2789
|
-
|
|
2790
|
-
Each generated router installs error handling (Express middleware, Fastify `setErrorHandler`, or Hono `app.onError`) that normalizes errors. Prisma error codes are mapped to appropriate HTTP status codes. Guard errors are mapped as follows: `ShapeError` and `CallerError` → 400, `PolicyError` → 403. In production, unmapped/internal 500-level errors return a generic `Internal server error` message. Client-error details such as validation or conflict messages may still be included.
|
|
2791
|
-
|
|
2792
|
-
For the Hono target, thrown `HTTPException` instances are caught by `app.onError` and converted to `{ "message": err.message }` with the exception's status code. Custom response bodies attached to `HTTPException` are not preserved — see [HTTPException normalization](#httpexception-normalization).
|
|
2793
|
-
|
|
2794
|
-
| Status | Description |
|
|
2795
|
-
| ------ | ------------------------------------------ |
|
|
2796
|
-
| 400 | Invalid parameters, body, or query |
|
|
2797
|
-
| 403 | Guard policy rejected |
|
|
2798
|
-
| 404 | Record not found |
|
|
2799
|
-
| 409 | Unique constraint or transaction conflict |
|
|
2800
|
-
| 500 | Internal server error, including transaction mode without transaction support |
|
|
2801
|
-
| 501 | Feature not supported by database provider |
|
|
2802
|
-
| 503 | Database connection pool timeout |
|
|
2803
|
-
|
|
2804
|
-
## Security
|
|
2805
|
-
|
|
2806
|
-
All incoming JSON bodies and query parameters are sanitized to reject `__proto__`, `constructor`, and `prototype` keys, preventing prototype pollution attacks.
|
|
2807
|
-
|
|
2808
|
-
## Documentation endpoints
|
|
2809
|
-
|
|
2810
|
-
### Automatic (registered by each router)
|
|
2811
|
-
|
|
2812
|
-
Each router automatically registers OpenAPI spec endpoints when not in production:
|
|
2813
|
-
|
|
2814
|
-
| Endpoint | Description |
|
|
2815
|
-
| ------------------------------- | --------------------- |
|
|
2816
|
-
| `/{modelname}/openapi.json` | OpenAPI 3.1 JSON spec |
|
|
2817
|
-
| `/{modelname}/openapi.yaml` | OpenAPI 3.1 YAML spec |
|
|
2818
|
-
|
|
2819
|
-
Actual paths depend on `customUrlPrefix` and `addModelPrefix` configuration. `{modelname}` is the lowercased model name (see [Path casing](#path-casing-in-generated-endpoints)).
|
|
2820
|
-
|
|
2821
|
-
The OpenAPI spec includes POST read endpoints when they are enabled (default). Each POST read operation appears with its own `operationId` and request body schema documenting the native JSON argument types.
|
|
2822
|
-
|
|
2823
|
-
### Manual (generated helpers, require mounting)
|
|
2824
|
-
|
|
2825
|
-
The generator produces helper functions that you mount yourself. Pass the same config object used for the router to keep docs and runtime in sync.
|
|
2826
|
-
|
|
2827
|
-
#### Express
|
|
2828
|
-
|
|
2829
|
-
```ts
|
|
2830
|
-
import {
|
|
2831
|
-
generateCombinedDocs,
|
|
2832
|
-
registerModelDocs,
|
|
2833
|
-
} from './generated/combinedDocs'
|
|
2834
|
-
|
|
2835
|
-
const userConfig = {
|
|
2836
|
-
findMany: { before: [authMiddleware] },
|
|
2837
|
-
create: {},
|
|
2838
|
-
findUnique: {},
|
|
2839
|
-
}
|
|
2840
|
-
|
|
2841
|
-
const postConfig = {
|
|
2842
|
-
enableAll: true,
|
|
2843
|
-
}
|
|
2844
|
-
|
|
2845
|
-
app.use(express.json())
|
|
2846
|
-
|
|
2847
|
-
app.use('/', UserRouter(userConfig))
|
|
2848
|
-
app.use('/', PostRouter(postConfig))
|
|
2849
|
-
|
|
2850
|
-
registerModelDocs(app, '/docs', {
|
|
2851
|
-
User: userConfig,
|
|
2852
|
-
Post: postConfig,
|
|
2853
|
-
})
|
|
2854
|
-
|
|
2855
|
-
app.get(
|
|
2856
|
-
'/docs',
|
|
2857
|
-
generateCombinedDocs({
|
|
2858
|
-
title: 'My API',
|
|
2859
|
-
modelConfigs: {
|
|
2860
|
-
User: userConfig,
|
|
2861
|
-
Post: postConfig,
|
|
2862
|
-
},
|
|
2863
|
-
}),
|
|
2864
|
-
)
|
|
2865
|
-
```
|
|
2866
|
-
|
|
2867
|
-
#### Fastify
|
|
2868
|
-
|
|
2869
|
-
```ts
|
|
2870
|
-
import {
|
|
2871
|
-
generateCombinedDocs,
|
|
2872
|
-
registerModelDocs,
|
|
2873
|
-
} from './generated/combinedDocs'
|
|
2874
|
-
|
|
2875
|
-
const userConfig = {
|
|
2876
|
-
findMany: { before: [async (request, reply) => { /* auth */ }] },
|
|
2877
|
-
create: {},
|
|
2878
|
-
findUnique: {},
|
|
2879
|
-
}
|
|
2880
|
-
|
|
2881
|
-
const postConfig = {
|
|
2882
|
-
enableAll: true,
|
|
2883
|
-
}
|
|
2884
|
-
|
|
2885
|
-
fastify.register(async (instance) => {
|
|
2886
|
-
await UserRoutes(instance, userConfig)
|
|
2887
|
-
})
|
|
2888
|
-
|
|
2889
|
-
fastify.register(async (instance) => {
|
|
2890
|
-
await PostRoutes(instance, postConfig)
|
|
2891
|
-
})
|
|
2892
|
-
|
|
2893
|
-
registerModelDocs(fastify, '/docs', {
|
|
2894
|
-
User: userConfig,
|
|
2895
|
-
Post: postConfig,
|
|
2896
|
-
})
|
|
2897
|
-
|
|
2898
|
-
fastify.get('/docs', generateCombinedDocs({
|
|
2899
|
-
title: 'My API',
|
|
2900
|
-
modelConfigs: {
|
|
2901
|
-
User: userConfig,
|
|
2902
|
-
Post: postConfig,
|
|
2903
|
-
},
|
|
2904
|
-
}))
|
|
2905
|
-
```
|
|
2906
|
-
|
|
2907
|
-
#### Hono
|
|
2908
|
-
|
|
2909
|
-
```ts
|
|
2910
|
-
import { Hono } from 'hono'
|
|
2911
|
-
import { PrismaClient } from '@prisma/client'
|
|
2912
|
-
import {
|
|
2913
|
-
generateCombinedDocs,
|
|
2914
|
-
registerModelDocs,
|
|
2915
|
-
} from './generated/combinedDocs'
|
|
2916
|
-
import { UserRouter } from './generated/User/UserRouter'
|
|
2917
|
-
import { PostRouter } from './generated/Post/PostRouter'
|
|
2918
|
-
|
|
2919
|
-
type Env = {
|
|
2920
|
-
Variables: {
|
|
2921
|
-
prisma: PrismaClient
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
const prisma = new PrismaClient()
|
|
2926
|
-
|
|
2927
|
-
const userConfig = {
|
|
2928
|
-
findMany: { before: [async (c) => { /* auth */ }] },
|
|
2929
|
-
create: {},
|
|
2930
|
-
findUnique: {},
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
const postConfig = {
|
|
2934
|
-
enableAll: true,
|
|
2935
|
-
}
|
|
2936
|
-
|
|
2937
|
-
const app = new Hono<Env>()
|
|
2938
|
-
|
|
2939
|
-
app.use('*', async (c, next) => {
|
|
2940
|
-
c.set('prisma', prisma)
|
|
2941
|
-
await next()
|
|
2942
|
-
})
|
|
2943
|
-
|
|
2944
|
-
app.route('/', UserRouter(userConfig))
|
|
2945
|
-
app.route('/', PostRouter(postConfig))
|
|
2946
|
-
|
|
2947
|
-
registerModelDocs(app, '/docs', {
|
|
2948
|
-
User: userConfig,
|
|
2949
|
-
Post: postConfig,
|
|
2950
|
-
})
|
|
2951
|
-
|
|
2952
|
-
app.get('/docs', generateCombinedDocs({
|
|
2953
|
-
title: 'My API',
|
|
2954
|
-
modelConfigs: {
|
|
2955
|
-
User: userConfig,
|
|
2956
|
-
Post: postConfig,
|
|
2957
|
-
},
|
|
2958
|
-
}))
|
|
2959
|
-
```
|
|
2960
|
-
|
|
2961
|
-
| Endpoint | Description |
|
|
2962
|
-
| --------------------------------- | ----------------------- |
|
|
2963
|
-
| `/docs` | Combined index page |
|
|
2964
|
-
| `/docs/{modelname}` | Contract view (default) |
|
|
2965
|
-
| `/docs/{modelname}?ui=scalar` | Scalar interactive UI |
|
|
2966
|
-
| `/docs/{modelname}?ui=json` | Raw JSON |
|
|
2967
|
-
| `/docs/{modelname}?ui=yaml` | Raw YAML |
|
|
2968
|
-
| `/docs/{modelname}?ui=playground` | Query playground |
|
|
2969
|
-
|
|
2970
|
-
The `?ui=playground` endpoint requires `prisma-query-builder-ui`. For Express and Fastify, the builder is auto-started in development. For Hono, the router never starts the builder; start it manually in a separate process (see [Query Builder](#query-builder)).
|
|
2971
|
-
|
|
2972
|
-
Disable in production via `NODE_ENV=production` or `DISABLE_OPENAPI=true`. Override with `disableOpenApi: false` in config to force-enable.
|
|
2973
|
-
|
|
2974
|
-
### Spec paths and mount prefixes
|
|
2975
|
-
|
|
2976
|
-
Use `specBasePath` to set the base path for OpenAPI spec and docs independently of route registration:
|
|
2977
|
-
|
|
2978
|
-
```ts
|
|
2979
|
-
const userConfig = {
|
|
2980
|
-
enableAll: true,
|
|
2981
|
-
specBasePath: '/api',
|
|
2982
|
-
}
|
|
2983
|
-
|
|
2984
|
-
app.use('/api', UserRouter(userConfig))
|
|
2985
|
-
```
|
|
2986
|
-
|
|
2987
|
-
When `specBasePath` is not set, `customUrlPrefix` is used for both runtime routes and spec paths.
|
|
2988
|
-
|
|
2989
|
-
## prisma-sql integration
|
|
2990
|
-
|
|
2991
|
-
When `prisma-sql` is installed, the generated handlers automatically attempt to use its `speedExtension` for optimized SQL execution. The extension activates only when a database connector is provided on the request context.
|
|
2992
|
-
|
|
2993
|
-
Set the connector in your middleware to activate the extension:
|
|
2994
|
-
|
|
2995
|
-
```ts
|
|
2996
|
-
import { PrismaClient } from '@prisma/client'
|
|
2997
|
-
import postgres from 'postgres'
|
|
2998
|
-
import { Hono } from 'hono'
|
|
2999
|
-
|
|
3000
|
-
const prisma = new PrismaClient()
|
|
3001
|
-
const sql = postgres(process.env.DATABASE_URL!)
|
|
3002
|
-
|
|
3003
|
-
// Express
|
|
3004
|
-
app.use(express.json())
|
|
3005
|
-
app.use((req, res, next) => {
|
|
3006
|
-
req.prisma = prisma
|
|
3007
|
-
req.postgres = sql
|
|
3008
|
-
next()
|
|
3009
|
-
})
|
|
3010
|
-
|
|
3011
|
-
// Fastify
|
|
3012
|
-
fastify.addHook('onRequest', async (request) => {
|
|
3013
|
-
request.prisma = prisma
|
|
3014
|
-
request.postgres = sql
|
|
3015
|
-
})
|
|
3016
|
-
|
|
3017
|
-
// Hono
|
|
3018
|
-
type Env = {
|
|
3019
|
-
Variables: {
|
|
3020
|
-
prisma: PrismaClient
|
|
3021
|
-
postgres: ReturnType<typeof postgres>
|
|
3022
|
-
}
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
const app = new Hono<Env>()
|
|
3026
|
-
|
|
3027
|
-
app.use('*', async (c, next) => {
|
|
3028
|
-
c.set('prisma', prisma)
|
|
3029
|
-
c.set('postgres', sql)
|
|
3030
|
-
await next()
|
|
3031
|
-
})
|
|
3032
|
-
```
|
|
3033
|
-
|
|
3034
|
-
Without a connector on the request context, the handlers use the standard PrismaClient. Set `DEBUG=true` in the environment to enable prisma-sql debug logging.
|
|
3035
|
-
|
|
3036
|
-
For SQLite, use `c.set('sqlite', sqliteConnector)` (Hono) or the equivalent on Express/Fastify, and add `sqlite` to the `Variables` type.
|
|
3037
|
-
|
|
3038
|
-
## Query parameter parsing
|
|
3039
|
-
|
|
3040
|
-
GET query values are parsed server-side. Strings starting with `{`, `[`, or `"` are JSON-parsed. The strings `true`, `false`, `null` are converted to their JS equivalents. Numeric conversion applies only to `take` and `skip`, and only when the value is a valid integer (e.g., `"10"` is parsed, `"10.5"` and `""` are not). Use `encodeQueryParams` on the client side to avoid encoding issues.
|
|
3041
|
-
|
|
3042
|
-
POST read endpoints bypass this parsing entirely — the JSON body is used as-is with native types.
|
|
3043
|
-
|
|
3044
|
-
On the Hono target, duplicate query keys collapse to the last value (`?a=1&a=2` → `a=2`). `encodeQueryParams` does not emit duplicate keys, so this only matters for hand-built query strings.
|
|
3045
|
-
|
|
3046
|
-
## Router schema
|
|
3047
|
-
|
|
3048
|
-
`{modelname}` in the paths below is the lowercased model name. For a `User` model, `/{modelname}/first` becomes `/user/first`. For `BlogPost`, it becomes `/blogpost/first`. See [Path casing in generated endpoints](#path-casing-in-generated-endpoints).
|
|
3049
|
-
|
|
3050
|
-
| Operation | Method | Path | Notes |
|
|
3051
|
-
| ------------------- | ------ | ---------------------------- | ---------------------------------- |
|
|
3052
|
-
| findMany | GET | `/{modelname}/` | |
|
|
3053
|
-
| findMany | POST | `/{modelname}/read` | POST read alternative |
|
|
3054
|
-
| findFirst | GET | `/{modelname}/first` | |
|
|
3055
|
-
| findFirst | POST | `/{modelname}/first` | POST read alternative |
|
|
3056
|
-
| findFirstOrThrow | GET | `/{modelname}/first/strict` | |
|
|
3057
|
-
| findFirstOrThrow | POST | `/{modelname}/first/strict` | POST read alternative |
|
|
3058
|
-
| findUnique | GET | `/{modelname}/unique` | |
|
|
3059
|
-
| findUnique | POST | `/{modelname}/unique` | POST read alternative |
|
|
3060
|
-
| findUniqueOrThrow | GET | `/{modelname}/unique/strict` | |
|
|
3061
|
-
| findUniqueOrThrow | POST | `/{modelname}/unique/strict` | POST read alternative |
|
|
3062
|
-
| findManyPaginated | GET | `/{modelname}/paginated` | |
|
|
3063
|
-
| findManyPaginated | POST | `/{modelname}/paginated` | POST read alternative |
|
|
3064
|
-
| count | GET | `/{modelname}/count` | |
|
|
3065
|
-
| count | POST | `/{modelname}/count` | POST read alternative |
|
|
3066
|
-
| aggregate | GET | `/{modelname}/aggregate` | |
|
|
3067
|
-
| aggregate | POST | `/{modelname}/aggregate` | POST read alternative |
|
|
3068
|
-
| groupBy | GET | `/{modelname}/groupby` | |
|
|
3069
|
-
| groupBy | POST | `/{modelname}/groupby` | POST read alternative |
|
|
3070
|
-
| create | POST | `/{modelname}/` | |
|
|
3071
|
-
| createMany | POST | `/{modelname}/many` | |
|
|
3072
|
-
| createManyAndReturn | POST | `/{modelname}/many/return` | |
|
|
3073
|
-
| update | PUT | `/{modelname}/` | |
|
|
3074
|
-
| updateMany | PUT | `/{modelname}/many` | |
|
|
3075
|
-
| updateManyAndReturn | PUT | `/{modelname}/many/return` | |
|
|
3076
|
-
| upsert | PATCH | `/{modelname}/` | |
|
|
3077
|
-
| delete | DELETE | `/{modelname}/` | |
|
|
3078
|
-
| deleteMany | DELETE | `/{modelname}/many` | |
|
|
3079
|
-
|
|
3080
|
-
Paths shown are relative suffixes. Actual paths include the model prefix (e.g., `/user/first`) unless `addModelPrefix: false`, and any `customUrlPrefix`.
|
|
3081
|
-
|
|
3082
|
-
POST read endpoints are enabled by default. Set `disablePostReads: true` to remove them.
|
|
3083
|
-
|
|
3084
|
-
The schema-wide `writeStrategy` option can change the behavior of `POST /{modelname}/many` and `PUT /{modelname}/many`. It does not change `DELETE /{modelname}/many`.
|
|
3085
|
-
|
|
3086
|
-
The schema-wide `findManyPaginatedMode` option changes the generated implementation behind `GET` and `POST /{modelname}/paginated`, but not the public response shape.
|
|
3087
|
-
|
|
3088
|
-
For the Express target, GET read endpoints can also stream SSE events when the request sends `Accept: text/event-stream`. SSE uses the same GET paths shown above; no additional routes are generated. See [Progressive Endpoint Composition](#progressive-endpoint-composition-express-sse).
|
|
3089
|
-
|
|
3090
|
-
## Skipping models
|
|
3091
|
-
|
|
3092
|
-
Add `/// generator off` to a model's documentation to skip generation. The marker must be on its own documentation line:
|
|
3093
|
-
|
|
3094
|
-
```prisma
|
|
3095
|
-
/// generator off
|
|
3096
|
-
model InternalLog {
|
|
3097
|
-
id Int @id
|
|
3098
|
-
}
|
|
3099
|
-
```
|
|
3100
|
-
|
|
3101
|
-
## Configuration
|
|
3102
|
-
|
|
3103
|
-
### Generator options
|
|
3104
|
-
|
|
3105
|
-
Generator options are configured in `schema.prisma` and apply schema-wide.
|
|
3106
|
-
|
|
3107
|
-
```prisma
|
|
3108
|
-
generator express {
|
|
3109
|
-
provider = "prisma-generator-express"
|
|
3110
|
-
target = "express"
|
|
3111
|
-
writeStrategy = "regular"
|
|
3112
|
-
findManyPaginatedMode = "promiseAll"
|
|
3113
|
-
dropGuard = false
|
|
3114
|
-
}
|
|
3115
|
-
```
|
|
3116
|
-
|
|
3117
|
-
| Option | Values | Default | Description |
|
|
3118
|
-
| ------ | ------ | ------- | ----------- |
|
|
3119
|
-
| `target` | `"express"`, `"fastify"`, `"hono"` | `"express"` | Selects the generated router target. |
|
|
3120
|
-
| `writeStrategy` | `"regular"`, `"throwOnNonReturning"`, `"forceReturn"` | `"regular"` | Controls only `createMany` and `updateMany`. See [Write strategy](#write-strategy). |
|
|
3121
|
-
| `findManyPaginatedMode` | `"promiseAll"`, `"transaction"` | `"promiseAll"` | Controls whether generated `findManyPaginated` handlers run data and count with `Promise.all` or inside an interactive transaction. See [findManyPaginated execution mode](#findmanypaginated-execution-mode). |
|
|
3122
|
-
| `dropGuard` | `true`, `false` | `false` | When `true`, generated routers never pass guard shapes to Prisma. Runtime `E2E=true` also disables guard in emitted routers, even when generator `dropGuard` is `false`. Route-level and operation-level `dropGuard` config fields do not exist. |
|
|
3123
|
-
|
|
3124
|
-
> Route-level and operation-level `dropGuard` config fields were removed because they were never read at runtime. Use generator `dropGuard = true` or `E2E === 'true'`.
|
|
3125
|
-
|
|
3126
|
-
### Express
|
|
3127
|
-
|
|
3128
|
-
```ts
|
|
3129
|
-
type PaginationCountSource =
|
|
3130
|
-
| { type?: 'delegate' }
|
|
3131
|
-
| {
|
|
3132
|
-
type: 'materializedView'
|
|
3133
|
-
relation: string
|
|
3134
|
-
schema?: string
|
|
3135
|
-
column?: string
|
|
3136
|
-
where?: Record<string, unknown>
|
|
3137
|
-
}
|
|
3138
|
-
|
|
3139
|
-
interface PaginationConfig {
|
|
3140
|
-
defaultLimit?: number
|
|
3141
|
-
maxLimit?: number
|
|
3142
|
-
distinctCountLimit?: number
|
|
3143
|
-
countSource?: PaginationCountSource
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
interface RouteConfig<TCtx = unknown> {
|
|
3147
|
-
enableAll?: boolean
|
|
3148
|
-
addModelPrefix?: boolean // default: true
|
|
3149
|
-
customUrlPrefix?: string
|
|
3150
|
-
specBasePath?: string
|
|
3151
|
-
disableOpenApi?: boolean
|
|
3152
|
-
disablePostReads?: boolean // default: false
|
|
3153
|
-
scalarCdnUrl?: string
|
|
3154
|
-
|
|
3155
|
-
openApiTitle?: string
|
|
3156
|
-
openApiDescription?: string
|
|
3157
|
-
openApiVersion?: string
|
|
3158
|
-
openApiServers?: OpenApiServerConfig[]
|
|
3159
|
-
openApiSecuritySchemes?: Record<string, OpenApiSecuritySchemeConfig>
|
|
3160
|
-
openApiSecurity?: Record<string, string[]>[]
|
|
3161
|
-
|
|
3162
|
-
guard?: {
|
|
3163
|
-
resolveVariant?: (req: Request) => string | undefined
|
|
3164
|
-
variantHeader?: string // default: 'x-api-variant'
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
resolveContext?: (req: Request) => TCtx | Promise<TCtx>
|
|
3168
|
-
|
|
3169
|
-
queryBuilder?: QueryBuilderConfig | false
|
|
3170
|
-
|
|
3171
|
-
pagination?: PaginationConfig
|
|
3172
|
-
|
|
3173
|
-
// read operation config
|
|
3174
|
-
findMany?: ReadOperationConfig<TCtx>
|
|
3175
|
-
findUnique?: ReadOperationConfig<TCtx>
|
|
3176
|
-
findUniqueOrThrow?: ReadOperationConfig<TCtx>
|
|
3177
|
-
findFirst?: ReadOperationConfig<TCtx>
|
|
3178
|
-
findFirstOrThrow?: ReadOperationConfig<TCtx>
|
|
3179
|
-
findManyPaginated?: ReadOperationConfig<TCtx>
|
|
3180
|
-
aggregate?: ReadOperationConfig<TCtx>
|
|
3181
|
-
count?: ReadOperationConfig<TCtx>
|
|
3182
|
-
groupBy?: ReadOperationConfig<TCtx>
|
|
3183
|
-
|
|
3184
|
-
// write operation config
|
|
3185
|
-
create?: OperationConfig
|
|
3186
|
-
createMany?: OperationConfig
|
|
3187
|
-
createManyAndReturn?: OperationConfig
|
|
3188
|
-
update?: OperationConfig
|
|
3189
|
-
updateMany?: OperationConfig
|
|
3190
|
-
updateManyAndReturn?: OperationConfig
|
|
3191
|
-
upsert?: OperationConfig
|
|
3192
|
-
delete?: OperationConfig
|
|
3193
|
-
deleteMany?: OperationConfig
|
|
3194
|
-
updateEach?: UpdateEachConfig
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
type ShapeOrFn<TShape, TCtx> = TShape | ((ctx: TCtx) => TShape)
|
|
3198
|
-
|
|
3199
|
-
type VariantEntry<TShape, TCtx, TBefore, TAfter = TBefore> = {
|
|
3200
|
-
shape: ShapeOrFn<TShape, TCtx>
|
|
3201
|
-
before?: TBefore[]
|
|
3202
|
-
after?: TAfter[]
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
|
-
type OperationConfig<
|
|
3206
|
-
TShape = Record<string, any>,
|
|
3207
|
-
TCtx = unknown,
|
|
3208
|
-
> = {
|
|
3209
|
-
before?: RequestHandler[]
|
|
3210
|
-
after?: RequestHandler[]
|
|
3211
|
-
pagination?: Partial<PaginationConfig>
|
|
3212
|
-
} & (
|
|
3213
|
-
| {
|
|
3214
|
-
shape?: ShapeOrFn<TShape, TCtx> | Record<string, ShapeOrFn<TShape, TCtx>>
|
|
3215
|
-
variants?: never
|
|
3216
|
-
}
|
|
3217
|
-
| {
|
|
3218
|
-
shape?: never
|
|
3219
|
-
variants: Record<
|
|
3220
|
-
string,
|
|
3221
|
-
VariantEntry<TShape, TCtx, RequestHandler>
|
|
3222
|
-
>
|
|
3223
|
-
}
|
|
3224
|
-
)
|
|
3225
|
-
|
|
3226
|
-
type ReadOperationConfig<
|
|
3227
|
-
TCtx = unknown,
|
|
3228
|
-
TShape = Record<string, any>,
|
|
3229
|
-
> = OperationConfig<TShape, TCtx> & {
|
|
3230
|
-
progressive?: Record<string, ProgressiveVariantConfig>
|
|
3231
|
-
progressiveStages?: Record<string, ProgressiveStage<TCtx>>
|
|
3232
|
-
}
|
|
3233
|
-
|
|
3234
|
-
interface UpdateEachConfig {
|
|
3235
|
-
before?: RequestHandler[]
|
|
3236
|
-
after?: RequestHandler[]
|
|
3237
|
-
}
|
|
3238
|
-
|
|
3239
|
-
type ManualProgressiveVariantConfig = {
|
|
3240
|
-
enabled?: boolean
|
|
3241
|
-
mode?: 'manual'
|
|
3242
|
-
stages: string[]
|
|
3243
|
-
}
|
|
3244
|
-
|
|
3245
|
-
type AutoIncludeProgressiveVariantConfig = {
|
|
3246
|
-
enabled?: boolean
|
|
3247
|
-
mode: 'autoInclude'
|
|
3248
|
-
fallback?: 'singleResult' | 'error'
|
|
3249
|
-
}
|
|
3250
|
-
|
|
3251
|
-
type ProgressiveVariantConfig =
|
|
3252
|
-
| ManualProgressiveVariantConfig
|
|
3253
|
-
| AutoIncludeProgressiveVariantConfig
|
|
3254
|
-
|
|
3255
|
-
type ProgressiveStageContext<TContext = unknown, TPrisma = any> = {
|
|
3256
|
-
ctx: TContext
|
|
3257
|
-
req: Request
|
|
3258
|
-
res: Response
|
|
3259
|
-
prisma: TPrisma
|
|
3260
|
-
variant: string
|
|
3261
|
-
accumulated: Record<string, unknown>
|
|
3262
|
-
signal: AbortSignal
|
|
3263
|
-
}
|
|
3264
|
-
|
|
3265
|
-
type ProgressivePatch = {
|
|
3266
|
-
key: string
|
|
3267
|
-
value: unknown
|
|
3268
|
-
}
|
|
3269
|
-
|
|
3270
|
-
type ProgressiveStopResult<T = unknown> = {
|
|
3271
|
-
stop: true
|
|
3272
|
-
data: T
|
|
3273
|
-
}
|
|
3274
|
-
|
|
3275
|
-
type ProgressiveStageResult<T = unknown> =
|
|
3276
|
-
| void
|
|
3277
|
-
| ProgressivePatch
|
|
3278
|
-
| ProgressivePatch[]
|
|
3279
|
-
| ProgressiveStopResult<T>
|
|
3280
|
-
|
|
3281
|
-
type ProgressiveStage<TContext = unknown, TPrisma = any, T = unknown> = (
|
|
3282
|
-
context: ProgressiveStageContext<TContext, TPrisma>,
|
|
3283
|
-
) => Promise<ProgressiveStageResult<T>>
|
|
3284
|
-
|
|
3285
|
-
interface QueryBuilderConfig {
|
|
3286
|
-
enabled?: boolean
|
|
3287
|
-
port?: number
|
|
3288
|
-
host?: string
|
|
3289
|
-
schemaPath?: string
|
|
3290
|
-
databaseUrl?: string
|
|
3291
|
-
}
|
|
3292
|
-
```
|
|
3293
|
-
|
|
3294
|
-
### Fastify
|
|
3295
|
-
|
|
3296
|
-
The Fastify config is identical except for hook and resolver types:
|
|
3297
|
-
|
|
3298
|
-
```ts
|
|
3299
|
-
type OperationConfig<TShape = Record<string, any>> = {
|
|
3300
|
-
before?: FastifyHookHandler[]
|
|
3301
|
-
after?: FastifyHookHandler[]
|
|
3302
|
-
pagination?: Partial<PaginationConfig>
|
|
3303
|
-
} & (
|
|
3304
|
-
| { shape?: TShape | ((ctx: unknown) => TShape) | Record<string, TShape | ((ctx: unknown) => TShape)>; variants?: never }
|
|
3305
|
-
| { shape?: never; variants: Record<string, { shape: TShape | ((ctx: unknown) => TShape); before?: FastifyHookHandler[]; after?: FastifyHookHandler[] }> }
|
|
3306
|
-
)
|
|
3307
|
-
|
|
3308
|
-
type FastifyHookHandler = (
|
|
3309
|
-
request: FastifyRequest,
|
|
3310
|
-
reply: FastifyReply,
|
|
3311
|
-
) => Promise<void> | void
|
|
3312
|
-
```
|
|
3313
|
-
|
|
3314
|
-
The `guard.resolveVariant` callback receives `FastifyRequest` instead of `Request`.
|
|
3315
|
-
|
|
3316
|
-
### Hono
|
|
3317
|
-
|
|
3318
|
-
The Hono config is identical except for hook and resolver types:
|
|
3319
|
-
|
|
3320
|
-
```ts
|
|
3321
|
-
type OperationConfig<TShape = Record<string, any>> = {
|
|
3322
|
-
before?: HonoBeforeHook[]
|
|
3323
|
-
after?: HonoAfterHook[]
|
|
3324
|
-
pagination?: Partial<PaginationConfig>
|
|
3325
|
-
} & (
|
|
3326
|
-
| { shape?: TShape | ((ctx: unknown) => TShape) | Record<string, TShape | ((ctx: unknown) => TShape)>; variants?: never }
|
|
3327
|
-
| { shape?: never; variants: Record<string, { shape: TShape | ((ctx: unknown) => TShape); before?: HonoBeforeHook[]; after?: HonoAfterHook[] }> }
|
|
3328
|
-
)
|
|
3329
|
-
|
|
3330
|
-
type HonoBeforeHook<Env extends { Variables: any } = any> = (
|
|
3331
|
-
c: Context<Env>,
|
|
3332
|
-
) => Promise<Response | void> | Response | void
|
|
3333
|
-
|
|
3334
|
-
type HonoAfterHook<Env extends { Variables: any } = any> = (
|
|
3335
|
-
c: Context<Env>,
|
|
3336
|
-
) => Promise<Response | void> | Response | void
|
|
3337
|
-
```
|
|
3338
|
-
|
|
3339
|
-
The `guard.resolveVariant` callback receives Hono's `Context`. Hono route hooks return `void` to continue, return a `Response` to short-circuit, or throw `HTTPException` to error. They do not receive `next`. Use normal Hono `app.use()` middleware outside the generated router when native middleware `next()` semantics are required.
|
|
3340
|
-
|
|
3341
|
-
The Hono router does not auto-start the Query Builder. Set `queryBuilder: false` to make the playground route return 404, or run `prisma-query-builder-ui` manually for development.
|
|
3342
|
-
|
|
3343
|
-
### Shared route options
|
|
3344
|
-
|
|
3345
|
-
These options are passed to the generated router at runtime. They are separate from schema-wide generator options such as `target`, `writeStrategy`, and `findManyPaginatedMode`.
|
|
3346
|
-
|
|
3347
|
-
`customUrlPrefix` is normalized to ensure a leading slash and strip trailing slashes.
|
|
3348
|
-
|
|
3349
|
-
`specBasePath` controls the base path used in OpenAPI spec paths and docs examples, independent of `customUrlPrefix`.
|
|
3350
|
-
|
|
3351
|
-
`disablePostReads` removes all POST read endpoints when set to `true`. POST read endpoints are enabled by default. This is a global setting — there is no per-operation toggle.
|
|
3352
|
-
|
|
3353
|
-
`resolveContext` is Express-only and is required for enabled progressive SSE variants. It is called before progressive stages run and its return value is passed to each stage as `ctx`.
|
|
3354
|
-
|
|
3355
|
-
`pagination` can be set at the router level and overridden per operation with `operation.pagination`. Endpoint-level pagination config is shallow-merged over router-level pagination config. `countSource` is only used by `findManyPaginated`.
|
|
3356
|
-
|
|
3357
|
-
`openApiServers` sets the `servers` array in the OpenAPI spec:
|
|
3358
|
-
|
|
3359
|
-
```ts
|
|
3360
|
-
UserRouter({
|
|
3361
|
-
enableAll: true,
|
|
3362
|
-
openApiServers: [
|
|
3363
|
-
{ url: 'https://api.example.com/v1', description: 'Production' },
|
|
3364
|
-
],
|
|
3365
|
-
})
|
|
3366
|
-
```
|
|
3367
|
-
|
|
3368
|
-
`openApiSecuritySchemes` and `openApiSecurity` set the security configuration in the OpenAPI spec:
|
|
3369
|
-
|
|
3370
|
-
```ts
|
|
3371
|
-
UserRouter({
|
|
3372
|
-
enableAll: true,
|
|
3373
|
-
openApiSecuritySchemes: {
|
|
3374
|
-
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
|
3375
|
-
},
|
|
3376
|
-
openApiSecurity: [{ bearerAuth: [] }],
|
|
3377
|
-
})
|
|
3378
|
-
```
|
|
3379
|
-
|
|
3380
|
-
## Global guard drop for E2E SQLite
|
|
3381
|
-
|
|
3382
|
-
Generated routers can globally skip Prisma guard in E2E mode.
|
|
3383
|
-
|
|
3384
|
-
This is used when the production Prisma schema and SQLite E2E schema intentionally differ. Example: production stores `normalized_skills` as `String[]`, while SQLite stores it as `Json?`. Guard shapes generated from the SQLite schema would reject scalar-list filters such as:
|
|
3385
|
-
|
|
3386
|
-
```ts
|
|
3387
|
-
{
|
|
3388
|
-
normalized_skills: {
|
|
3389
|
-
hasSome: ['typescript', 'react']
|
|
3390
|
-
}
|
|
3391
|
-
}
|
|
3392
|
-
```
|
|
3393
|
-
|
|
3394
|
-
In E2E, the generated router should skip guard and let the E2E Prisma extension rewrite supported scalar-list filters before the SQL extension runs.
|
|
3395
|
-
|
|
3396
|
-
### Behavior
|
|
3397
|
-
|
|
3398
|
-
Generated routers use this effective guard-drop flag:
|
|
3399
|
-
|
|
3400
|
-
```ts
|
|
3401
|
-
const DROP_GUARD = GENERATOR_DROP_GUARD || process.env.E2E === 'true'
|
|
3402
|
-
```
|
|
3403
|
-
|
|
3404
|
-
When `DROP_GUARD` is true, the generated router calls Prisma directly instead of calling `delegate.guard(...)`. Before doing so, it still applies forced values and default projection behavior using vendored, dependency-free generated runtime helpers.
|
|
3405
|
-
|
|
3406
|
-
Caller routing is not dropped. Active and dropped modes use the same exact/default/parameterized resolver and the same declared matched key. Missing, unknown, ambiguous, and reserved-key routing failures return 400 after operation before-hooks in both modes.
|
|
3407
|
-
|
|
3408
|
-
Generated dropped/E2E router output has no runtime import from `prisma-guard`, the configured guard runtime import path, or a generated guard bridge. Type-only imports of generated guard shape declarations are allowed because they are erased at build time.
|
|
3409
|
-
|
|
3410
|
-
### Production safety
|
|
3411
|
-
|
|
3412
|
-
Production must keep:
|
|
3413
|
-
|
|
3414
|
-
```env
|
|
3415
|
-
E2E=false
|
|
3416
|
-
```
|
|
3417
|
-
|
|
3418
|
-
or leave `E2E` unset.
|
|
3419
|
-
|
|
3420
|
-
Do not set generator config `dropGuard = true` for normal production generation unless guard should be disabled everywhere.
|
|
3421
|
-
|
|
3422
|
-
Recommended setup:
|
|
3423
|
-
|
|
3424
|
-
```prisma
|
|
3425
|
-
generator express {
|
|
3426
|
-
provider = "prisma-generator-express"
|
|
3427
|
-
target = "express"
|
|
3428
|
-
}
|
|
3429
|
-
```
|
|
3430
|
-
|
|
3431
|
-
Then generated routers drop guard only when runtime env has:
|
|
3432
|
-
|
|
3433
|
-
```env
|
|
3434
|
-
E2E=true
|
|
3435
|
-
```
|
|
3436
|
-
|
|
3437
|
-
### E2E scalar-list support
|
|
3438
|
-
|
|
3439
|
-
E2E SQLite scalar-list support is intentionally narrow.
|
|
3440
|
-
|
|
3441
|
-
Supported:
|
|
3442
|
-
|
|
3443
|
-
```ts
|
|
3444
|
-
{
|
|
3445
|
-
normalized_skills: {
|
|
3446
|
-
hasSome: ['typescript', 'react']
|
|
3447
|
-
}
|
|
3448
|
-
}
|
|
3449
|
-
```
|
|
3450
|
-
|
|
3451
|
-
The E2E Prisma extension pre-resolves matching row IDs using SQLite `json_each`, then rewrites the query to:
|
|
3452
|
-
|
|
3453
|
-
```ts
|
|
3454
|
-
{
|
|
3455
|
-
id: {
|
|
3456
|
-
in: [...]
|
|
3457
|
-
}
|
|
3458
|
-
}
|
|
3459
|
-
```
|
|
3460
|
-
|
|
3461
|
-
This happens before the SQL extension compiles the Prisma query.
|
|
3462
|
-
|
|
3463
|
-
### Extension chain
|
|
3464
|
-
|
|
3465
|
-
E2E Prisma client chain:
|
|
3466
|
-
|
|
3467
|
-
```ts
|
|
3468
|
-
basePrisma
|
|
3469
|
-
.$extends(normalizedSkillsExtension())
|
|
3470
|
-
.$extends(guardExtension)
|
|
3471
|
-
.$extends(e2eListOpsExtension())
|
|
3472
|
-
.$extends(speed)
|
|
3473
|
-
```
|
|
3474
|
-
|
|
3475
|
-
Guard extension remains in the chain, but generated routers do not pass guard shapes when `E2E=true`.
|
|
3476
|
-
|
|
3477
|
-
### Supported E2E scalar-list scope
|
|
3478
|
-
|
|
3479
|
-
Supported:
|
|
3480
|
-
|
|
3481
|
-
```text
|
|
3482
|
-
hasSome only
|
|
3483
|
-
allowlisted JSON-backed scalar-list fields only
|
|
3484
|
-
top-level where
|
|
3485
|
-
AND / OR / NOT recursion
|
|
3486
|
-
findMany / findFirst / count / aggregate / groupBy / updateMany / deleteMany
|
|
3487
|
-
```
|
|
3488
|
-
|
|
3489
|
-
Out of scope:
|
|
3490
|
-
|
|
3491
|
-
```text
|
|
3492
|
-
has
|
|
3493
|
-
hasEvery
|
|
3494
|
-
isEmpty
|
|
3495
|
-
equals
|
|
3496
|
-
nested relation where
|
|
3497
|
-
include/select nested where
|
|
3498
|
-
_count.where
|
|
3499
|
-
findUnique
|
|
3500
|
-
update
|
|
3501
|
-
delete
|
|
3502
|
-
upsert
|
|
3503
|
-
null values inside hasSome input
|
|
3504
|
-
```
|
|
3505
|
-
|
|
3506
|
-
Unsupported cases should fail loudly instead of being partially rewritten.
|
|
3507
|
-
|
|
3508
|
-
### Why not regenerate guard from SQLite schema
|
|
3509
|
-
|
|
3510
|
-
Do not depend on SQLite-generated guard output for this path.
|
|
3511
|
-
|
|
3512
|
-
The SQLite schema is a derived test schema. It may intentionally erase Postgres-only field information such as scalar-list types. Guard correctness belongs to the production schema, not the SQLite test schema.
|
|
3513
|
-
|
|
3514
|
-
Global E2E guard drop keeps this path simple:
|
|
3515
|
-
|
|
3516
|
-
```text
|
|
3517
|
-
production keeps guard
|
|
3518
|
-
E2E skips guard
|
|
3519
|
-
E2E list-op extension handles hasSome
|
|
3520
|
-
SQL extension receives normal id filters
|
|
3521
|
-
```
|
|
3522
|
-
|
|
3523
|
-
### Required implementation notes
|
|
3524
|
-
|
|
3525
|
-
Generated router code computes:
|
|
3526
|
-
|
|
3527
|
-
```ts
|
|
3528
|
-
const DROP_GUARD = GENERATOR_DROP_GUARD || process.env.E2E === 'true'
|
|
298
|
+
```bash
|
|
299
|
+
MCP_TOKEN=dev-token DATABASE_URL="$DATABASE_URL" npx tsx server.ts
|
|
3529
300
|
```
|
|
3530
301
|
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
- active mode stores the normalized guard shape for `delegate.guard(...)`
|
|
3534
|
-
- dropped mode applies the same matched shape locally before operation before-hooks
|
|
3535
|
-
|
|
3536
|
-
On a routing failure, the router stores the failure, runs operation before-hooks, then returns HTTP 400 before variant hooks or the handler. This ordering is identical across Express, Fastify, and Hono.
|
|
3537
|
-
|
|
3538
|
-
The dropped runtime must remain self-contained. Generated code must not require `prisma-guard` to be installed or resolvable when `dropGuard=true` or `E2E=true`.
|
|
3539
|
-
|
|
3540
|
-
## Environment variables
|
|
3541
|
-
|
|
3542
|
-
| Variable | Default | Description |
|
|
3543
|
-
| ----------------- | ------- | ----------------------------------- |
|
|
3544
|
-
| `DISABLE_OPENAPI` | `false` | Disable OpenAPI endpoints |
|
|
3545
|
-
| `NODE_ENV` | - | Set to `production` to disable docs |
|
|
3546
|
-
| `DEBUG` | `false` | Enable prisma-sql debug logging |
|
|
302
|
+
Maintainer-facing design notes: [`ARCHITECTURE.md`](https://github.com/multipliedtwice/prisma-generator-express/blob/master/ARCHITECTURE.md).
|
|
3547
303
|
|
|
3548
304
|
## License
|
|
3549
305
|
|