prisma-generator-express 1.27.0 → 1.29.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.
Files changed (49) hide show
  1. package/README.md +255 -16
  2. package/dist/constants.d.ts +1 -0
  3. package/dist/generators/generateFastifyHandler.d.ts +4 -0
  4. package/dist/generators/generateFastifyHandler.js +78 -0
  5. package/dist/generators/generateFastifyHandler.js.map +1 -0
  6. package/dist/generators/generateOperationCore.d.ts +6 -0
  7. package/dist/generators/generateOperationCore.js +534 -0
  8. package/dist/generators/generateOperationCore.js.map +1 -0
  9. package/dist/generators/generateQueryBuilderHelper.js +85 -69
  10. package/dist/generators/generateQueryBuilderHelper.js.map +1 -1
  11. package/dist/generators/generateRouter.js +1 -25
  12. package/dist/generators/generateRouter.js.map +1 -1
  13. package/dist/generators/generateRouterFastify.d.ts +5 -0
  14. package/dist/generators/generateRouterFastify.js +512 -0
  15. package/dist/generators/generateRouterFastify.js.map +1 -0
  16. package/dist/generators/generateUnifiedDocs.d.ts +2 -1
  17. package/dist/generators/generateUnifiedDocs.js +147 -82
  18. package/dist/generators/generateUnifiedDocs.js.map +1 -1
  19. package/dist/generators/generateUnifiedHandler.d.ts +0 -1
  20. package/dist/generators/generateUnifiedHandler.js +47 -516
  21. package/dist/generators/generateUnifiedHandler.js.map +1 -1
  22. package/dist/generators/generateUnifiedScalarUI.d.ts +2 -0
  23. package/dist/generators/generateUnifiedScalarUI.js +127 -1324
  24. package/dist/generators/generateUnifiedScalarUI.js.map +1 -1
  25. package/dist/index.js +33 -8
  26. package/dist/index.js.map +1 -1
  27. package/dist/utils/copyFiles.d.ts +2 -1
  28. package/dist/utils/copyFiles.js +64 -38
  29. package/dist/utils/copyFiles.js.map +1 -1
  30. package/dist/utils/writeFileSafely.js +3 -0
  31. package/dist/utils/writeFileSafely.js.map +1 -1
  32. package/package.json +4 -1
  33. package/src/client/encodeQueryParams.ts +1 -1
  34. package/src/constants.ts +2 -0
  35. package/src/copy/createOutputValidatorMiddleware.ts +9 -12
  36. package/src/copy/docsRenderer.ts +1285 -0
  37. package/src/copy/parseQueryParams.ts +4 -8
  38. package/src/copy/routeConfig.ts +10 -4
  39. package/src/generators/generateFastifyHandler.ts +86 -0
  40. package/src/generators/generateOperationCore.ts +545 -0
  41. package/src/generators/generateQueryBuilderHelper.ts +86 -70
  42. package/src/generators/generateRouter.ts +1 -25
  43. package/src/generators/generateRouterFastify.ts +522 -0
  44. package/src/generators/generateUnifiedDocs.ts +164 -81
  45. package/src/generators/generateUnifiedHandler.ts +45 -533
  46. package/src/generators/generateUnifiedScalarUI.ts +134 -1323
  47. package/src/index.ts +45 -9
  48. package/src/utils/copyFiles.ts +79 -45
  49. package/src/utils/writeFileSafely.ts +4 -0
@@ -0,0 +1,1285 @@
1
+ import type { RouteConfig } from './routeConfig.js'
2
+ import { OPERATION_DEFS, isOperationEnabled } from './operationDefinitions.js'
3
+
4
+ const _env = typeof process !== 'undefined' && process.env ? process.env : {} as Record<string, string | undefined>
5
+
6
+ export interface FieldMeta {
7
+ name: string
8
+ kind: string
9
+ type: string
10
+ isList: boolean
11
+ isRequired: boolean
12
+ hasDefaultValue: boolean
13
+ isUpdatedAt: boolean
14
+ documentation: string | null
15
+ isId: boolean
16
+ isUnique: boolean
17
+ relationFromFields?: string[]
18
+ }
19
+
20
+ export interface EnumMeta {
21
+ name: string
22
+ values: { name: string }[]
23
+ }
24
+
25
+ export type DocsUI = 'docs' | 'scalar' | 'json' | 'yaml' | 'playground'
26
+
27
+ export type DocsConfig = RouteConfig & {
28
+ docsTitle?: string
29
+ docsUi?: DocsUI
30
+ }
31
+
32
+ export interface DocsModelContext {
33
+ fields: FieldMeta[]
34
+ enums: EnumMeta[]
35
+ compoundId: { fields: string[] } | null
36
+ compoundUniques: { name: string; fields: string[] }[]
37
+ exampleValues: Record<string, unknown>
38
+ }
39
+
40
+ interface OpDetail {
41
+ transport: string
42
+ required: string[]
43
+ optional: string[]
44
+ responseDesc: string
45
+ errors: number[]
46
+ supportsSelect: boolean
47
+ supportsInclude: boolean
48
+ supportsOmit: boolean
49
+ notes: string
50
+ }
51
+
52
+ const DEFAULT_SCALAR_CDN = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference'
53
+ const PRISM_CSS = 'https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism.min.css'
54
+ const PRISM_JS = 'https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js'
55
+ const PRISM_JSON = 'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-json.min.js'
56
+
57
+ const OP_DETAIL_MAP: Record<string, OpDetail> = {
58
+ findMany: {
59
+ transport: 'GET query params',
60
+ required: [],
61
+ optional: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
62
+ responseDesc: 'Array of records',
63
+ errors: [400, 403, 500, 501, 503],
64
+ supportsSelect: true,
65
+ supportsInclude: true,
66
+ supportsOmit: true,
67
+ notes: 'Pagination limits may apply when configured.',
68
+ },
69
+ findUnique: {
70
+ transport: 'GET query params',
71
+ required: ['where'],
72
+ optional: ['select', 'include', 'omit'],
73
+ responseDesc: 'Single record or null',
74
+ errors: [400, 403, 500, 501, 503],
75
+ supportsSelect: true,
76
+ supportsInclude: true,
77
+ supportsOmit: true,
78
+ notes: 'Returns null (not 404) when no record matches.',
79
+ },
80
+ findUniqueOrThrow: {
81
+ transport: 'GET query params',
82
+ required: ['where'],
83
+ optional: ['select', 'include', 'omit'],
84
+ responseDesc: 'Single record',
85
+ errors: [400, 403, 404, 500, 501, 503],
86
+ supportsSelect: true,
87
+ supportsInclude: true,
88
+ supportsOmit: true,
89
+ notes: 'Returns 404 when no record matches.',
90
+ },
91
+ findFirst: {
92
+ transport: 'GET query params',
93
+ required: [],
94
+ optional: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
95
+ responseDesc: 'Single record or null',
96
+ errors: [400, 403, 500, 501, 503],
97
+ supportsSelect: true,
98
+ supportsInclude: true,
99
+ supportsOmit: true,
100
+ notes: 'Returns null (not 404) when no record matches.',
101
+ },
102
+ findFirstOrThrow: {
103
+ transport: 'GET query params',
104
+ required: [],
105
+ optional: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
106
+ responseDesc: 'Single record',
107
+ errors: [400, 403, 404, 500, 501, 503],
108
+ supportsSelect: true,
109
+ supportsInclude: true,
110
+ supportsOmit: true,
111
+ notes: 'Returns 404 when no record matches.',
112
+ },
113
+ findManyPaginated: {
114
+ transport: 'GET query params',
115
+ required: [],
116
+ optional: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
117
+ responseDesc: '{ data: Record[], total: number, hasMore: boolean }',
118
+ errors: [400, 403, 409, 500, 501, 503],
119
+ supportsSelect: true,
120
+ supportsInclude: true,
121
+ supportsOmit: true,
122
+ notes: 'Wraps findMany with total count. hasMore is reliable for forward offset pagination (skip + take) only. Distinct count over 100k falls back to approximate total. 409 possible on transaction conflict.',
123
+ },
124
+ create: {
125
+ transport: 'POST JSON body',
126
+ required: ['data'],
127
+ optional: ['select', 'include', 'omit'],
128
+ responseDesc: 'Created record (201)',
129
+ errors: [400, 403, 409, 500, 501, 503],
130
+ supportsSelect: true,
131
+ supportsInclude: true,
132
+ supportsOmit: true,
133
+ notes: '409 on unique constraint violation.',
134
+ },
135
+ createMany: {
136
+ transport: 'POST JSON body',
137
+ required: ['data'],
138
+ optional: ['skipDuplicates'],
139
+ responseDesc: '{ count: number } (201)',
140
+ errors: [400, 403, 409, 500, 501, 503],
141
+ supportsSelect: false,
142
+ supportsInclude: false,
143
+ supportsOmit: false,
144
+ notes: 'data is an array of scalar-only inputs. Nested relation writes are not supported. skipDuplicates silently ignores conflicts (not supported on all providers).',
145
+ },
146
+ createManyAndReturn: {
147
+ transport: 'POST JSON body',
148
+ required: ['data'],
149
+ optional: ['skipDuplicates', 'select', 'include', 'omit'],
150
+ responseDesc: 'Array of created records (201)',
151
+ errors: [400, 403, 409, 500, 501, 503],
152
+ supportsSelect: true,
153
+ supportsInclude: true,
154
+ supportsOmit: true,
155
+ notes: 'Like createMany but returns created records. data items are scalar-only. Requires Prisma 5.14.0+, PostgreSQL/CockroachDB/SQLite only. The order of returned records is not guaranteed.',
156
+ },
157
+ update: {
158
+ transport: 'PUT JSON body',
159
+ required: ['where', 'data'],
160
+ optional: ['select', 'include', 'omit'],
161
+ responseDesc: 'Updated record',
162
+ errors: [400, 403, 404, 409, 500, 501, 503],
163
+ supportsSelect: true,
164
+ supportsInclude: true,
165
+ supportsOmit: true,
166
+ notes: '404 when the record to update is not found. 409 on unique constraint violation or transaction conflict.',
167
+ },
168
+ updateMany: {
169
+ transport: 'PUT JSON body',
170
+ required: ['where', 'data'],
171
+ optional: [],
172
+ responseDesc: '{ count: number }',
173
+ errors: [400, 403, 409, 500, 501, 503],
174
+ supportsSelect: false,
175
+ supportsInclude: false,
176
+ supportsOmit: false,
177
+ notes: 'Updates all matching records with scalar-only data. Nested relation writes are not supported. Returns count, not records. 409 on unique constraint violation.',
178
+ },
179
+ updateManyAndReturn: {
180
+ transport: 'PUT JSON body',
181
+ required: ['where', 'data'],
182
+ optional: ['select', 'include', 'omit'],
183
+ responseDesc: 'Array of updated records',
184
+ errors: [400, 403, 409, 500, 501, 503],
185
+ supportsSelect: true,
186
+ supportsInclude: true,
187
+ supportsOmit: true,
188
+ notes: 'Like updateMany but returns updated records. data is scalar-only. Requires Prisma 6.2.0+, PostgreSQL/CockroachDB/SQLite only. 409 on unique constraint violation.',
189
+ },
190
+ upsert: {
191
+ transport: 'PATCH JSON body',
192
+ required: ['where', 'create', 'update'],
193
+ optional: ['select', 'include', 'omit'],
194
+ responseDesc: 'Created or updated record',
195
+ errors: [400, 403, 409, 500, 501, 503],
196
+ supportsSelect: true,
197
+ supportsInclude: true,
198
+ supportsOmit: true,
199
+ notes: 'Creates if not found, updates if found.',
200
+ },
201
+ delete: {
202
+ transport: 'DELETE JSON body',
203
+ required: ['where'],
204
+ optional: ['select', 'include', 'omit'],
205
+ responseDesc: 'Deleted record',
206
+ errors: [400, 403, 404, 500, 501, 503],
207
+ supportsSelect: true,
208
+ supportsInclude: true,
209
+ supportsOmit: true,
210
+ notes: '404 when the record to delete is not found.',
211
+ },
212
+ deleteMany: {
213
+ transport: 'DELETE JSON body',
214
+ required: ['where'],
215
+ optional: [],
216
+ responseDesc: '{ count: number }',
217
+ errors: [400, 403, 500, 501, 503],
218
+ supportsSelect: false,
219
+ supportsInclude: false,
220
+ supportsOmit: false,
221
+ notes: 'Deletes all matching records. Returns count, not records.',
222
+ },
223
+ count: {
224
+ transport: 'GET query params',
225
+ required: [],
226
+ optional: ['where', 'orderBy', 'cursor', 'take', 'skip', 'select'],
227
+ responseDesc: 'Integer, or per-field count object when select is provided',
228
+ errors: [400, 403, 500, 501, 503],
229
+ supportsSelect: false,
230
+ supportsInclude: false,
231
+ supportsOmit: false,
232
+ notes: 'select here means count-specific field selection, not record field selection.',
233
+ },
234
+ aggregate: {
235
+ transport: 'GET query params',
236
+ required: [],
237
+ optional: ['where', 'orderBy', 'cursor', 'take', 'skip', '_count', '_avg', '_sum', '_min', '_max'],
238
+ responseDesc: 'Object with requested aggregate fields (_count, _avg, _sum, _min, _max)',
239
+ errors: [400, 403, 500, 501, 503],
240
+ supportsSelect: false,
241
+ supportsInclude: false,
242
+ supportsOmit: false,
243
+ notes: '_avg, _sum only apply to numeric fields.',
244
+ },
245
+ groupBy: {
246
+ transport: 'GET query params',
247
+ required: ['by'],
248
+ optional: ['where', 'orderBy', 'having', 'take', 'skip', '_count', '_avg', '_sum', '_min', '_max'],
249
+ responseDesc: 'Array of objects, each with grouped field values and requested aggregates',
250
+ errors: [400, 403, 500, 501, 503],
251
+ supportsSelect: false,
252
+ supportsInclude: false,
253
+ supportsOmit: false,
254
+ notes: 'by is a JSON-encoded array of scalar field names. orderBy is required when using skip or take. Response contains only the by-fields plus requested aggregates.',
255
+ },
256
+ }
257
+
258
+ function exampleValue(ctx: DocsModelContext, fieldName: string): unknown {
259
+ return ctx.exampleValues[fieldName] ?? 'example'
260
+ }
261
+
262
+ function compoundWhereExample(ctx: DocsModelContext): Record<string, any> | null {
263
+ if (ctx.compoundId) {
264
+ const keyName = ctx.compoundId.fields.join('_')
265
+ const val: Record<string, unknown> = {}
266
+ for (const f of ctx.compoundId.fields) val[f] = exampleValue(ctx, f)
267
+ return { [keyName]: val }
268
+ }
269
+ if (ctx.compoundUniques.length > 0) {
270
+ const u = ctx.compoundUniques[0]
271
+ const keyName = u.name || u.fields.join('_')
272
+ const val: Record<string, unknown> = {}
273
+ for (const f of u.fields) val[f] = exampleValue(ctx, f)
274
+ return { [keyName]: val }
275
+ }
276
+ return null
277
+ }
278
+
279
+ export function isOpenApiDisabled(disableOpenApi?: boolean): boolean {
280
+ if (disableOpenApi === true) return true
281
+ if (disableOpenApi === false) return false
282
+ return _env.DISABLE_OPENAPI === 'true' || _env.NODE_ENV === 'production'
283
+ }
284
+
285
+ export function isPlaygroundAvailable(config: DocsConfig): boolean {
286
+ if (config.queryBuilder === false) return false
287
+ if (typeof config.queryBuilder === 'object' && config.queryBuilder.enabled === false) return false
288
+ if (_env.NODE_ENV === 'production') return false
289
+ return true
290
+ }
291
+
292
+ function escapeHtml(input: string): string {
293
+ return input
294
+ .replace(/&/g, '&amp;')
295
+ .replace(/</g, '&lt;')
296
+ .replace(/>/g, '&gt;')
297
+ .replace(/"/g, '&quot;')
298
+ .replace(/'/g, '&#039;')
299
+ }
300
+
301
+ function safeJsonForHtml(obj: unknown): string {
302
+ return JSON.stringify(obj).replace(/</g, '\\u003c')
303
+ }
304
+
305
+ export function renderScalar(_modelName: string, spec: unknown, title: string, cdnUrl?: string): string {
306
+ const scalarSrc = cdnUrl || DEFAULT_SCALAR_CDN
307
+ return `<!DOCTYPE html>
308
+ <html lang="en">
309
+ <head>
310
+ <meta charset="utf-8" />
311
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
312
+ <title>${escapeHtml(title)}</title>
313
+ </head>
314
+ <body>
315
+ <script id="api-reference" type="application/json">${safeJsonForHtml(spec)}</script>
316
+ <script src="${escapeHtml(scalarSrc)}"></script>
317
+ </body>
318
+ </html>`
319
+ }
320
+
321
+ export function renderPlayground(modelName: string, config: DocsConfig): string {
322
+ const qbConfig = (typeof config.queryBuilder === 'object' && config.queryBuilder) ? config.queryBuilder : {}
323
+ const host = qbConfig.host || 'localhost'
324
+ const port = qbConfig.port || 5173
325
+ const baseUrl = `http://${host}:${port}`
326
+ const iframeSrc = `${baseUrl}?embedded=true&hideHeader=true`
327
+ const title = config.docsTitle || `${modelName} Query Playground`
328
+
329
+ const openApiLinks =
330
+ '<a class="inline-block border border-gray-200 bg-white rounded-full py-1 px-2.5 text-[11px] no-underline text-inherit hover:border-gray-400" href="?ui=docs">Docs</a>' +
331
+ '<a class="inline-block border border-gray-200 bg-white rounded-full py-1 px-2.5 text-[11px] no-underline text-inherit hover:border-gray-400" href="?ui=scalar">Scalar</a>' +
332
+ '<a class="inline-block border border-gray-200 bg-white rounded-full py-1 px-2.5 text-[11px] no-underline text-inherit hover:border-gray-400" href="?ui=json">JSON</a>' +
333
+ '<a class="inline-block border border-gray-200 bg-white rounded-full py-1 px-2.5 text-[11px] no-underline text-inherit hover:border-gray-400" href="?ui=yaml">YAML</a>' +
334
+ '<a class="inline-block border border-gray-900 bg-gray-900 text-white rounded-full py-1 px-2.5 text-[11px] no-underline" href="?ui=playground">Playground</a>'
335
+
336
+ return `<!DOCTYPE html>
337
+ <html lang="en">
338
+ <head>
339
+ <meta charset="utf-8" />
340
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
341
+ <title>${escapeHtml(title)}</title>
342
+ <script src="https://cdn.tailwindcss.com"></script>
343
+ </head>
344
+ <body class="m-0 bg-white text-gray-900 font-sans">
345
+ <div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 flex-wrap gap-2">
346
+ <div class="flex items-center gap-3">
347
+ <span class="text-sm font-bold">Query Playground</span>
348
+ <span class="text-xs text-gray-500 font-mono">${escapeHtml(modelName)}</span>
349
+ </div>
350
+ <div class="flex gap-1.5 flex-wrap">${openApiLinks}</div>
351
+ </div>
352
+ <div class="h-[calc(100vh-52px)] w-full" id="frame-container">
353
+ <iframe
354
+ id="qb-frame"
355
+ class="w-full h-full border-none"
356
+ src="${escapeHtml(iframeSrc)}"
357
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
358
+ loading="lazy"
359
+ ></iframe>
360
+ </div>
361
+ <script>
362
+ (function() {
363
+ var frame = document.getElementById('qb-frame');
364
+ var container = document.getElementById('frame-container');
365
+ var timer = null;
366
+
367
+ frame.addEventListener('error', showError);
368
+
369
+ timer = setTimeout(function() {
370
+ try {
371
+ if (!frame.contentWindow || !frame.contentWindow.document) {
372
+ showError();
373
+ }
374
+ } catch(e) {}
375
+ }, 5000);
376
+
377
+ frame.addEventListener('load', function() {
378
+ if (timer) clearTimeout(timer);
379
+ });
380
+
381
+ function showError() {
382
+ if (timer) clearTimeout(timer);
383
+ container.innerHTML =
384
+ '<div class="flex items-center justify-center h-[calc(100vh-52px)] text-gray-500 text-sm flex-col gap-3">' +
385
+ '<div>Query builder is not running</div>' +
386
+ '<code class="font-mono bg-gray-50 py-2 px-3.5 rounded-md text-[13px]">npx prisma-query-builder</code>' +
387
+ '<div class="text-xs mt-1">Expected at: ${escapeHtml(baseUrl)}</div>' +
388
+ '</div>';
389
+ }
390
+ })();
391
+ </script>
392
+ </body>
393
+ </html>`
394
+ }
395
+
396
+ function isScalarField(f: FieldMeta): boolean {
397
+ return f.kind === 'scalar'
398
+ }
399
+
400
+ function isRelationField(f: FieldMeta): boolean {
401
+ return f.kind === 'object'
402
+ }
403
+
404
+ function isEnumField(f: FieldMeta): boolean {
405
+ return f.kind === 'enum'
406
+ }
407
+
408
+ function scalarFilterOperators(scalarType: string): string[] {
409
+ if (scalarType === 'String') {
410
+ return [
411
+ 'equals',
412
+ 'in',
413
+ 'notIn',
414
+ 'lt',
415
+ 'lte',
416
+ 'gt',
417
+ 'gte',
418
+ 'contains',
419
+ 'startsWith',
420
+ 'endsWith',
421
+ 'mode',
422
+ 'not',
423
+ ]
424
+ }
425
+
426
+ if (
427
+ scalarType === 'Int' ||
428
+ scalarType === 'BigInt' ||
429
+ scalarType === 'Float' ||
430
+ scalarType === 'Decimal'
431
+ ) {
432
+ return ['equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'not']
433
+ }
434
+
435
+ if (scalarType === 'DateTime') {
436
+ return ['equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'not']
437
+ }
438
+
439
+ if (scalarType === 'Boolean') {
440
+ return ['equals', 'not']
441
+ }
442
+
443
+ if (scalarType === 'Json') {
444
+ return ['equals', 'path', 'string_contains', 'array_contains', 'not']
445
+ }
446
+
447
+ if (scalarType === 'Bytes') {
448
+ return ['equals', 'in', 'notIn', 'not']
449
+ }
450
+
451
+ return ['equals', 'in', 'notIn', 'not']
452
+ }
453
+
454
+ function listFilterOperators(): string[] {
455
+ return ['has', 'hasEvery', 'hasSome', 'isEmpty']
456
+ }
457
+
458
+ function whereFieldKind(f: FieldMeta): string {
459
+ if (isRelationField(f)) return f.isList ? 'relation-list' : 'relation-single'
460
+ if (isEnumField(f)) return 'enum'
461
+ if (isScalarField(f)) return f.isList ? 'scalar-list' : 'scalar'
462
+ return 'unknown'
463
+ }
464
+
465
+ function whereFieldShape(f: FieldMeta): string {
466
+ const kind = whereFieldKind(f)
467
+
468
+ if (kind === 'relation-list') {
469
+ return '{ some?: RelatedWhere, every?: RelatedWhere, none?: RelatedWhere }'
470
+ }
471
+
472
+ if (kind === 'relation-single') {
473
+ return '{ is?: RelatedWhere, isNot?: RelatedWhere }'
474
+ }
475
+
476
+ if (kind === 'scalar-list') {
477
+ return '{ has?, hasEvery?, hasSome?, isEmpty? }'
478
+ }
479
+
480
+ if (kind === 'scalar') {
481
+ return 'scalar value OR filter object'
482
+ }
483
+
484
+ if (kind === 'enum') {
485
+ return 'enum value OR enum filter'
486
+ }
487
+
488
+ return 'n/a'
489
+ }
490
+
491
+ function describeFieldType(f: FieldMeta): string {
492
+ const base = String(f.type) + (f.isList ? '[]' : '')
493
+ const optional = f.isRequired ? '' : ' (optional/nullable)'
494
+ const flags = [
495
+ f.isId ? 'id' : '',
496
+ f.isUnique ? 'unique' : '',
497
+ f.hasDefaultValue ? 'default' : '',
498
+ f.isUpdatedAt ? 'updatedAt' : '',
499
+ ].filter(Boolean)
500
+ const suffix = flags.length ? ' [' + flags.join(', ') + ']' : ''
501
+ return base + optional + suffix
502
+ }
503
+
504
+ function jsonBlock(v: unknown): string {
505
+ return '<pre class="my-2 rounded-xl !p-3 overflow-auto text-xs"><code class="language-json">' + escapeHtml(JSON.stringify(v, null, 2)) + '</code></pre>'
506
+ }
507
+
508
+ function codeBlock(text: string): string {
509
+ return '<pre class="my-2 rounded-xl !p-3 overflow-auto text-xs"><code class="language-javascript">' + escapeHtml(text) + '</code></pre>'
510
+ }
511
+
512
+ function anchors(): { id: string; label: string }[] {
513
+ return [
514
+ { id: 'ops', label: '1. Operations' },
515
+ { id: 'transport', label: '2. Transport' },
516
+ { id: 'args-read', label: '3. Read Args Reference' },
517
+ { id: 'args-write', label: '4. Write Args Reference' },
518
+ { id: 'where', label: '5. where' },
519
+ { id: 'select-include', label: '6. select, include & omit' },
520
+ { id: 'nested-writes', label: '7. Nested Writes' },
521
+ { id: 'order', label: '8. orderBy / cursor / distinct' },
522
+ { id: 'pagination', label: '9. Pagination' },
523
+ { id: 'errors', label: '10. Error Handling' },
524
+ { id: 'examples', label: '11. Examples' },
525
+ { id: 'guard-shapes', label: '12. Guard Shapes' },
526
+ { id: 'runtime', label: '13. Runtime Notes' },
527
+ ]
528
+ }
529
+
530
+ function normalizeExamplePrefix(p: string): string {
531
+ if (!p) return ''
532
+ let result = p.replace(/\/$/, '')
533
+ if (result && !result.startsWith('/')) result = '/' + result
534
+ return result
535
+ }
536
+
537
+ function buildExampleBasePath(modelName: string, config: DocsConfig): string {
538
+ const prefixSource = config.specBasePath ?? config.customUrlPrefix ?? ''
539
+ const prefix = normalizeExamplePrefix(prefixSource)
540
+ const modelPrefix = config.addModelPrefix !== false ? '/' + modelName.toLowerCase() : ''
541
+ return prefix + modelPrefix
542
+ }
543
+
544
+ function buildFullPath(basePath: string, suffix: string): string {
545
+ if (!suffix) return basePath || '/'
546
+ return basePath ? basePath + suffix : suffix
547
+ }
548
+
549
+ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModelContext): string {
550
+ const title = config.docsTitle || modelName + ' API'
551
+ const generatedAt = new Date().toISOString()
552
+
553
+ const modelLower = modelName.charAt(0).toLowerCase() + modelName.slice(1)
554
+ const exampleBasePath = buildExampleBasePath(modelName, config)
555
+
556
+ const scalarFields = ctx.fields.filter((f) => isScalarField(f) || isEnumField(f))
557
+ const relationFields = ctx.fields.filter((f) => isRelationField(f))
558
+
559
+ const uniqueFields = ctx.fields.filter((f) => f.isId || f.isUnique)
560
+ const requiredCreateFields = scalarFields.filter((sf) => sf.isRequired && !sf.hasDefaultValue && !sf.isUpdatedAt)
561
+ const listRelations = relationFields.filter((f) => f.isList)
562
+ const singleRelations = relationFields.filter((f) => !f.isList)
563
+
564
+ const ops = OPERATION_DEFS
565
+ .filter((d) => isOperationEnabled(config as Record<string, any>, d))
566
+ .map((d) => {
567
+ const detail = OP_DETAIL_MAP[d.name]
568
+ return {
569
+ op: d.name,
570
+ method: d.method.toUpperCase(),
571
+ path: buildFullPath(exampleBasePath, d.pathSuffix),
572
+ transport: detail ? detail.transport : d.method === 'get' ? 'GET query params' : 'JSON body',
573
+ responseDesc: detail ? detail.responseDesc : '',
574
+ errors: detail ? detail.errors.join(', ') : '',
575
+ required: detail ? detail.required : [],
576
+ optional: detail ? detail.optional : [],
577
+ supportsSelect: detail ? detail.supportsSelect : false,
578
+ supportsInclude: detail ? detail.supportsInclude : false,
579
+ supportsOmit: detail ? detail.supportsOmit : false,
580
+ notes: detail ? detail.notes : '',
581
+ }
582
+ })
583
+
584
+ const firstUnique = uniqueFields[0]
585
+ const firstUniqueExample = firstUnique ? exampleValue(ctx, firstUnique.name) : null
586
+ const compoundWhere = !firstUnique ? compoundWhereExample(ctx) : null
587
+ const uniqueWhereExample = firstUnique
588
+ ? { [firstUnique.name]: firstUniqueExample }
589
+ : compoundWhere
590
+
591
+ const firstFilterFieldName = firstUnique
592
+ ? firstUnique.name
593
+ : (ctx.compoundId ? ctx.compoundId.fields[0] : null)
594
+ || (ctx.compoundUniques.length > 0 ? ctx.compoundUniques[0].fields[0] : null)
595
+
596
+ const firstStringField = scalarFields.find((f) => f.type === 'String')
597
+ const firstBooleanField = scalarFields.find((f) => f.type === 'Boolean')
598
+
599
+ const whereExample: Record<string, any> = {}
600
+ const andClauses: Record<string, any>[] = []
601
+ if (firstFilterFieldName) {
602
+ andClauses.push({ [firstFilterFieldName]: { equals: exampleValue(ctx, firstFilterFieldName) } })
603
+ }
604
+ if (firstStringField) {
605
+ andClauses.push({ [firstStringField.name]: { contains: 'example', mode: 'insensitive' } })
606
+ }
607
+ if (andClauses.length > 0) {
608
+ whereExample.AND = andClauses
609
+ }
610
+ if (firstBooleanField) {
611
+ whereExample.OR = [{ [firstBooleanField.name]: { equals: true } }]
612
+ }
613
+
614
+ const selectExample: any = {}
615
+ for (const f of scalarFields.slice(0, 10)) selectExample[f.name] = true
616
+
617
+ const includeExample: any = {}
618
+ for (const f of relationFields.slice(0, 6)) includeExample[f.name] = true
619
+
620
+ const omitExample: any = {}
621
+ const omitCandidates = scalarFields.filter((f) => !f.isId && !f.isUnique)
622
+ for (const f of omitCandidates.slice(0, 3)) omitExample[f.name] = true
623
+
624
+ const orderByField = firstUnique
625
+ ? firstUnique.name
626
+ : firstFilterFieldName
627
+
628
+ const findManyQueryArgs: any = {
629
+ where: whereExample,
630
+ select: selectExample,
631
+ orderBy: orderByField ? { [orderByField]: 'asc' } : undefined,
632
+ take: 20,
633
+ skip: 0,
634
+ }
635
+ if (!findManyQueryArgs.orderBy) delete findManyQueryArgs.orderBy
636
+
637
+ const findManyFetchExample =
638
+ 'import { encodeQueryParams } from "./client/encodeQueryParams"\n\n' +
639
+ 'const params = encodeQueryParams(' + JSON.stringify(findManyQueryArgs, null, 2) + ')\n\n' +
640
+ 'const res = await fetch(BASE_URL + "' + exampleBasePath + '?" + params)\n' +
641
+ 'const data = await res.json()'
642
+
643
+ const findUniqueFetchExample = uniqueWhereExample
644
+ ? 'const params = encodeQueryParams({\n' +
645
+ ' where: ' + JSON.stringify(uniqueWhereExample) + ',\n' +
646
+ ' include: ' + JSON.stringify(includeExample) + '\n' +
647
+ '})\n\n' +
648
+ 'const res = await fetch(BASE_URL + "' + exampleBasePath + '/unique?" + params)\n' +
649
+ 'const data = await res.json()'
650
+ : null
651
+
652
+ const createBodyExample: any = { data: {} }
653
+ for (const f of requiredCreateFields.slice(0, 5)) {
654
+ createBodyExample.data[f.name] = exampleValue(ctx, f.name)
655
+ }
656
+
657
+ const createFetchExample =
658
+ 'const res = await fetch(BASE_URL + "' + exampleBasePath + '", {\n' +
659
+ ' method: "POST",\n' +
660
+ ' headers: { "Content-Type": "application/json" },\n' +
661
+ ' body: JSON.stringify(' + JSON.stringify(createBodyExample, null, 2) + ')\n' +
662
+ '})\n' +
663
+ 'const created = await res.json()'
664
+
665
+ const updateBodyExample: any = uniqueWhereExample
666
+ ? { where: uniqueWhereExample, data: {} }
667
+ : null
668
+ if (updateBodyExample) {
669
+ const firstEditableString = scalarFields.find((sf) => sf.type === 'String' && !sf.isId)
670
+ if (firstEditableString) {
671
+ updateBodyExample.data[firstEditableString.name] = 'updated'
672
+ }
673
+ }
674
+
675
+ const updateFetchExample = updateBodyExample
676
+ ? 'const res = await fetch(BASE_URL + "' + exampleBasePath + '", {\n' +
677
+ ' method: "PUT",\n' +
678
+ ' headers: { "Content-Type": "application/json" },\n' +
679
+ ' body: JSON.stringify(' + JSON.stringify(updateBodyExample, null, 2) + ')\n' +
680
+ '})\n' +
681
+ 'const updated = await res.json()'
682
+ : null
683
+
684
+ const deleteFetchExample = uniqueWhereExample
685
+ ? 'const res = await fetch(BASE_URL + "' + exampleBasePath + '", {\n' +
686
+ ' method: "DELETE",\n' +
687
+ ' headers: { "Content-Type": "application/json" },\n' +
688
+ ' body: JSON.stringify({\n' +
689
+ ' where: ' + JSON.stringify(uniqueWhereExample) + '\n' +
690
+ ' })\n' +
691
+ '})\n' +
692
+ 'const deleted = await res.json()'
693
+ : null
694
+
695
+ const guardVariantHeader = config.guard?.variantHeader || 'x-api-variant'
696
+
697
+ const guardFetchExample = uniqueWhereExample
698
+ ? 'const params = encodeQueryParams({\n' +
699
+ ' where: ' + JSON.stringify(uniqueWhereExample) + '\n' +
700
+ '})\n\n' +
701
+ 'const res = await fetch(BASE_URL + "' + exampleBasePath + '/unique?" + params, {\n' +
702
+ ' headers: { "' + guardVariantHeader + '": "admin" }\n' +
703
+ '})\n' +
704
+ 'const data = await res.json()'
705
+ : null
706
+
707
+ const writeFieldRows = ctx.fields.map((f) => {
708
+ let writeContract = ''
709
+ if (isRelationField(f)) {
710
+ writeContract = f.isList ? 'Nested list write object' : 'Nested single write object'
711
+ } else if (f.hasDefaultValue || f.isUpdatedAt) {
712
+ writeContract = 'Optional on create (has default)'
713
+ } else if (f.isRequired) {
714
+ writeContract = 'Required on create'
715
+ } else {
716
+ writeContract = 'Optional (nullable)'
717
+ }
718
+ return {
719
+ name: f.name,
720
+ type: describeFieldType(f),
721
+ writeContract,
722
+ }
723
+ })
724
+
725
+ const argsReferenceRead = {
726
+ findMany: {
727
+ where: 'WhereInput',
728
+ select: 'Select',
729
+ include: 'Include',
730
+ omit: 'Omit',
731
+ orderBy: 'OrderByInput | OrderByInput[]',
732
+ cursor: 'UniqueInput',
733
+ take: 'number',
734
+ skip: 'number',
735
+ distinct: 'ScalarFieldEnum | ScalarFieldEnum[]',
736
+ },
737
+ findUnique: {
738
+ where: 'UniqueInput (required)',
739
+ select: 'Select',
740
+ include: 'Include',
741
+ omit: 'Omit',
742
+ },
743
+ findFirst: {
744
+ where: 'WhereInput',
745
+ select: 'Select',
746
+ include: 'Include',
747
+ omit: 'Omit',
748
+ orderBy: 'OrderByInput | OrderByInput[]',
749
+ cursor: 'UniqueInput',
750
+ take: 'number',
751
+ skip: 'number',
752
+ distinct: 'ScalarFieldEnum | ScalarFieldEnum[]',
753
+ },
754
+ count: {
755
+ where: 'WhereInput',
756
+ orderBy: 'OrderByInput | OrderByInput[]',
757
+ cursor: 'UniqueInput',
758
+ take: 'number',
759
+ skip: 'number',
760
+ select: 'true | { _all?: true, fieldName?: true, ... }',
761
+ },
762
+ aggregate: {
763
+ where: 'WhereInput',
764
+ orderBy: 'OrderByInput | OrderByInput[]',
765
+ cursor: 'UniqueInput',
766
+ take: 'number',
767
+ skip: 'number',
768
+ _count: 'true | CountAggregateInput',
769
+ _avg: 'AvgAggregateInput',
770
+ _sum: 'SumAggregateInput',
771
+ _min: 'MinAggregateInput',
772
+ _max: 'MaxAggregateInput',
773
+ },
774
+ groupBy: {
775
+ by: 'ScalarFieldEnum[] (required)',
776
+ where: 'WhereInput',
777
+ orderBy: 'OrderByWithAggregationInput | OrderByWithAggregationInput[] (required when using skip or take)',
778
+ having: 'ScalarWhereWithAggregatesInput',
779
+ take: 'number',
780
+ skip: 'number',
781
+ _count: 'true | CountAggregateInput',
782
+ _avg: 'AvgAggregateInput',
783
+ _sum: 'SumAggregateInput',
784
+ _min: 'MinAggregateInput',
785
+ _max: 'MaxAggregateInput',
786
+ },
787
+ }
788
+
789
+ const argsReferenceWrite = {
790
+ create: {
791
+ data: modelName + 'CreateInput (required)',
792
+ select: 'Select',
793
+ include: 'Include',
794
+ omit: 'Omit',
795
+ },
796
+ createMany: {
797
+ data: modelName + 'CreateManyInput[] (required, scalar-only)',
798
+ skipDuplicates: 'boolean',
799
+ },
800
+ createManyAndReturn: {
801
+ data: modelName + 'CreateManyInput[] (required, scalar-only)',
802
+ skipDuplicates: 'boolean',
803
+ select: 'Select',
804
+ include: 'Include',
805
+ omit: 'Omit',
806
+ },
807
+ update: {
808
+ where: 'UniqueInput (required)',
809
+ data: modelName + 'UpdateInput (required)',
810
+ select: 'Select',
811
+ include: 'Include',
812
+ omit: 'Omit',
813
+ },
814
+ updateMany: {
815
+ where: 'WhereInput (required)',
816
+ data: modelName + 'UpdateManyMutationInput (required, scalar-only)',
817
+ },
818
+ updateManyAndReturn: {
819
+ where: 'WhereInput (required)',
820
+ data: modelName + 'UpdateManyMutationInput (required, scalar-only)',
821
+ select: 'Select',
822
+ include: 'Include',
823
+ omit: 'Omit',
824
+ },
825
+ upsert: {
826
+ where: 'UniqueInput (required)',
827
+ create: modelName + 'CreateInput (required)',
828
+ update: modelName + 'UpdateInput (required)',
829
+ select: 'Select',
830
+ include: 'Include',
831
+ omit: 'Omit',
832
+ },
833
+ delete: {
834
+ where: 'UniqueInput (required)',
835
+ select: 'Select',
836
+ include: 'Include',
837
+ omit: 'Omit',
838
+ },
839
+ deleteMany: {
840
+ where: 'WhereInput (required)',
841
+ },
842
+ }
843
+
844
+ const transportNotes = [
845
+ 'GET endpoints: Prisma args as JSON-encoded query parameter strings via encodeQueryParams.',
846
+ 'POST/PUT/DELETE/PATCH endpoints: Prisma args as JSON request body. Body must be a JSON object.',
847
+ 'findManyPaginated returns { data, total, hasMore }. hasMore is reliable for forward offset pagination only.',
848
+ 'Batch mutations (createMany, updateMany, deleteMany) return { count }. Batch data inputs are scalar-only — nested relation writes are not supported.',
849
+ 'findUnique and findFirst return null (not 404) when no record matches. Use the OrThrow variants for 404 behavior.',
850
+ 'createManyAndReturn requires Prisma 5.14.0+, updateManyAndReturn requires Prisma 6.2.0+. Both are limited to PostgreSQL, CockroachDB, and SQLite.',
851
+ ]
852
+
853
+ const errorRows = [
854
+ { status: '400', description: 'Bad request', causes: 'Invalid JSON body, invalid query parameters, query validation failure, guard shape rejection, field value out of range, non-object request body.' },
855
+ { status: '403', description: 'Forbidden', causes: 'Guard policy rejected the operation.' },
856
+ { status: '404', description: 'Not found', causes: 'Record not found. Only from OrThrow operations, update, and delete.' },
857
+ { status: '409', description: 'Conflict', causes: 'Unique constraint violation on create/update/upsert, or transaction conflict (e.g. in findManyPaginated).' },
858
+ { status: '500', description: 'Internal server error', causes: 'Database error, table/column missing, raw query failure, or unhandled error.' },
859
+ { status: '501', description: 'Not implemented', causes: 'Database provider does not support the requested feature.' },
860
+ { status: '503', description: 'Service unavailable', causes: 'Database connection pool timeout.' },
861
+ ]
862
+
863
+ const hasPlayground = isPlaygroundAvailable(config)
864
+
865
+ const chipClass = 'inline-block border border-gray-200 bg-gray-50 rounded-full py-[5px] px-2.5 text-xs no-underline text-inherit hover:border-gray-400'
866
+
867
+ const openApiLinks =
868
+ '<a class="' + chipClass + ' !bg-gray-900 !text-white !border-gray-900" href="?ui=docs">Docs</a>' +
869
+ '<a class="' + chipClass + '" href="?ui=scalar">Scalar</a>' +
870
+ '<a class="' + chipClass + '" href="?ui=json">JSON</a>' +
871
+ '<a class="' + chipClass + '" href="?ui=yaml">YAML</a>' +
872
+ (hasPlayground ? '<a class="' + chipClass + '" href="?ui=playground">Playground</a>' : '')
873
+
874
+ const tocHtml = '<ol class="m-0 pl-[18px]">' + anchors().map((a) => '<li class="my-1.5"><a href="#' + escapeHtml(a.id) + '" class="text-inherit">' + escapeHtml(a.label) + '</a></li>').join('') + '</ol>'
875
+
876
+ const whereRows = ctx.fields.map((f) => {
877
+ const kind = whereFieldKind(f)
878
+ const shape = whereFieldShape(f)
879
+ const filterOps =
880
+ kind === 'scalar'
881
+ ? scalarFilterOperators(String(f.type)).map((x) => '<span class="inline-block border border-gray-200 bg-gray-50 rounded-full py-0.5 px-2 text-[11px] mr-1.5 mb-0.5 font-mono">' + escapeHtml(x) + '</span>').join(' ')
882
+ : kind === 'enum'
883
+ ? ['equals', 'in', 'notIn', 'not'].map((x) => '<span class="inline-block border border-gray-200 bg-gray-50 rounded-full py-0.5 px-2 text-[11px] mr-1.5 mb-0.5 font-mono">' + escapeHtml(x) + '</span>').join(' ')
884
+ : kind === 'scalar-list'
885
+ ? listFilterOperators().map((x) => '<span class="inline-block border border-gray-200 bg-gray-50 rounded-full py-0.5 px-2 text-[11px] mr-1.5 mb-0.5 font-mono">' + escapeHtml(x) + '</span>').join(' ')
886
+ : kind === 'relation-single'
887
+ ? ['is', 'isNot'].map((x) => '<span class="inline-block border border-gray-200 bg-gray-50 rounded-full py-0.5 px-2 text-[11px] mr-1.5 mb-0.5 font-mono">' + escapeHtml(x) + '</span>').join(' ')
888
+ : kind === 'relation-list'
889
+ ? ['some', 'every', 'none'].map((x) => '<span class="inline-block border border-gray-200 bg-gray-50 rounded-full py-0.5 px-2 text-[11px] mr-1.5 mb-0.5 font-mono">' + escapeHtml(x) + '</span>').join(' ')
890
+ : '<span class="text-gray-500">n/a</span>'
891
+
892
+ const doc = f.documentation ? '<div class="text-gray-500 mt-1.5">' + escapeHtml(String(f.documentation)) + '</div>' : ''
893
+
894
+ return (
895
+ '<tr>' +
896
+ '<td class="text-left p-2 border-b border-gray-200 align-top font-mono">' + escapeHtml(f.name) + '</td>' +
897
+ '<td class="text-left p-2 border-b border-gray-200 align-top font-mono">' + escapeHtml(kind) + '</td>' +
898
+ '<td class="text-left p-2 border-b border-gray-200 align-top font-mono">' + escapeHtml(describeFieldType(f)) + '</td>' +
899
+ '<td class="text-left p-2 border-b border-gray-200 align-top"><div class="text-gray-500 font-mono">' + escapeHtml(shape) + '</div><div class="mt-2">' + filterOps + '</div>' + doc + '</td>' +
900
+ '</tr>'
901
+ )
902
+ }).join('')
903
+
904
+ const whereCoreShapes = {
905
+ where: {
906
+ AND: ['WhereInput', 'WhereInput[]'],
907
+ OR: ['WhereInput[]'],
908
+ NOT: ['WhereInput', 'WhereInput[]'],
909
+ field: 'ScalarFilter | scalar value | RelationFilter | EnumFilter',
910
+ },
911
+ }
912
+
913
+ const selectRules = [
914
+ 'select and include cannot be used together at the same level.',
915
+ 'omit cannot be used together with select at the same level. omit can be used with include.',
916
+ 'select: booleans for scalars, nested objects for relations.',
917
+ 'include: booleans for relations, nested include/select for deep loading.',
918
+ 'omit: booleans for scalar fields to exclude from the response.',
919
+ ]
920
+
921
+ const orderRules = [
922
+ 'orderBy: { field: "asc" | "desc" } or array for multiple.',
923
+ 'cursor: unique selector, e.g. { id: 123 }.',
924
+ 'distinct: scalar field names, e.g. ["email", "status"].',
925
+ 'take/skip applied after cursor/orderBy.',
926
+ ]
927
+
928
+ const paginationNotes = [
929
+ 'findMany and findManyPaginated support offset pagination via take and skip.',
930
+ 'Both also support cursor-based pagination via cursor + take.',
931
+ 'findManyPaginated wraps findMany with a total count query and returns { data, total, hasMore }.',
932
+ 'hasMore is reliable for forward offset pagination (skip + take) only. With cursor pagination or negative take, hasMore may be inaccurate.',
933
+ 'When the server config sets pagination.defaultLimit, take is automatically applied to findMany and findManyPaginated if omitted.',
934
+ 'When the server config sets pagination.maxLimit, take is capped by absolute value to that limit for findMany and findManyPaginated. This applies to both positive and negative take values.',
935
+ 'Clients cannot detect these server-side limits from the API alone. Check with the API provider for configured limits.',
936
+ ]
937
+
938
+ const nestedWriteListOps = [
939
+ { key: 'create', desc: 'Create new related records inline. Accepts a single object or array.' },
940
+ { key: 'connect', desc: 'Connect existing records by unique identifier. Accepts a single object or array.' },
941
+ { key: 'connectOrCreate', desc: 'Connect if exists, create if not. Each item: { where, create }.' },
942
+ { key: 'createMany', desc: 'Bulk create related records. Shape: { data: [...], skipDuplicates?: boolean }.' },
943
+ { key: 'set', desc: 'Replace all connections. Provide an array of unique identifiers.' },
944
+ { key: 'disconnect', desc: 'Disconnect related records without deleting them.' },
945
+ { key: 'delete', desc: 'Delete related records by unique identifier.' },
946
+ { key: 'update', desc: 'Update related records. Each item: { where, data }.' },
947
+ { key: 'updateMany', desc: 'Bulk update related records matching a filter. Each item: { where, data }.' },
948
+ { key: 'deleteMany', desc: 'Bulk delete related records matching a filter.' },
949
+ { key: 'upsert', desc: 'Create or update related records. Each item: { where, create, update }.' },
950
+ ]
951
+
952
+ const nestedWriteSingleOps = [
953
+ { key: 'create', desc: 'Create a new related record inline.' },
954
+ { key: 'connect', desc: 'Connect an existing record by unique identifier.' },
955
+ { key: 'connectOrCreate', desc: 'Connect if exists, create if not. Shape: { where, create }.' },
956
+ { key: 'disconnect', desc: 'Disconnect the related record (set relation to null). Pass true.' },
957
+ { key: 'delete', desc: 'Delete the related record. Pass true.' },
958
+ { key: 'update', desc: 'Update the related record inline with update input.' },
959
+ { key: 'upsert', desc: 'Create the related record if it does not exist, update if it does. Shape: { create, update }.' },
960
+ ]
961
+
962
+ const guardShapeInfo = [
963
+ 'When a guard shape is configured on an operation, prisma-guard validates and enforces allowed query patterns before the query reaches the database.',
964
+ 'Named shapes route to different guard configs based on a caller value resolved from the <span class="font-mono">' + escapeHtml(guardVariantHeader) + '</span> header or a custom resolver function.',
965
+ 'Forced values (literals instead of true) are injected server-side and cannot be overridden by the client.',
966
+ ]
967
+
968
+ const runtimeNotes = [
969
+ '<strong>Query parameter parsing:</strong> GET query values are parsed server-side. Strings starting with <span class="font-mono">{</span>, <span class="font-mono">[</span>, or <span class="font-mono">"</span> are JSON-parsed. The strings <span class="font-mono">true</span>, <span class="font-mono">false</span>, <span class="font-mono">null</span> are converted to their JS equivalents. Numeric conversion only applies to <span class="font-mono">take</span> and <span class="font-mono">skip</span>. Use <span class="font-mono">encodeQueryParams</span> to avoid encoding issues.',
970
+ '<strong>Request body validation:</strong> All write endpoints require a JSON object body. Sending <span class="font-mono">null</span>, arrays, or non-object JSON values returns 400.',
971
+ '<strong>Documentation in production:</strong> Docs endpoints are disabled by default when <span class="font-mono">NODE_ENV=production</span> or <span class="font-mono">DISABLE_OPENAPI=true</span>. To enable in production, set <span class="font-mono">disableOpenApi: false</span> in the route config.',
972
+ '<strong>Paginated query atomicity:</strong> findManyPaginated wraps data + count in a database transaction when available. If interactive transactions are not supported (e.g. some edge adapters), the queries run separately and data/total may be slightly inconsistent under concurrent writes.',
973
+ '<strong>Distinct count approximation:</strong> When findManyPaginated is used with distinct and the number of unique values exceeds 100,000, the total falls back to a non-distinct count which may overcount. The hasMore value is affected accordingly.',
974
+ '<strong>Serialization:</strong> BigInt values are serialized as strings. Bytes/Buffer values are serialized as base64 strings. Decimal values are serialized as strings. DateTime values are serialized as ISO 8601 strings.',
975
+ '<strong>Playground:</strong> The query playground embeds an iframe to a local prisma-query-builder-ui instance. It connects to your real database using the configured DATABASE_URL. It is disabled in production and when queryBuilder is set to false or queryBuilder.enabled is set to false.',
976
+ '<strong>Prototype pollution protection:</strong> All incoming JSON bodies and query parameters are sanitized to reject __proto__, constructor, and prototype keys.',
977
+ '<strong>Batch operation safety:</strong> deleteMany, updateMany, and updateManyAndReturn require a where field in the request body. Requests without where are rejected with 400 to prevent accidental mass operations.',
978
+ '<strong>Bulk write constraints:</strong> createMany, createManyAndReturn, updateMany, and updateManyAndReturn accept scalar-only data inputs. Nested relation writes are not supported in these operations.',
979
+ '<strong>Provider compatibility:</strong> createManyAndReturn requires Prisma 5.14.0+ and is limited to PostgreSQL, CockroachDB, and SQLite. updateManyAndReturn requires Prisma 6.2.0+ with the same provider restrictions. skipDuplicates is not supported on all database providers.',
980
+ '<strong>omit compatibility:</strong> The omit parameter requires Prisma 5.13.0+ (preview) or 6.2.0+ (GA). On older Prisma versions, requests using omit will return 400.',
981
+ ]
982
+
983
+ const noUniqueFieldNote = '<div class="bg-gray-50 border border-gray-200 rounded-xl p-3 text-xs text-gray-500">This model has no unique or id fields suitable for a generated example. Use the unique constraint from your schema.</div>'
984
+
985
+ const thClass = 'text-left p-2 border-b border-gray-200 align-top font-black'
986
+ const tdClass = 'text-left p-2 border-b border-gray-200 align-top'
987
+ const calloutClass = 'bg-gray-50 border border-gray-200 rounded-xl p-3'
988
+
989
+ const html = `<!DOCTYPE html>
990
+ <html lang="en">
991
+ <head>
992
+ <meta charset="utf-8" />
993
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
994
+ <title>${escapeHtml(title)}</title>
995
+ <script src="https://cdn.tailwindcss.com"></script>
996
+ <link rel="stylesheet" href="${PRISM_CSS}" />
997
+ <style>
998
+ .example :not(pre)>code[class*=language-], .example pre[class*=language-] { border: 1px solid rgb(229 230 234); }
999
+ :not(pre)>code[class*=language-], pre[class*=language-] { background: #f9fafb; }
1000
+ pre[class*="language-"] { border-radius: 12px; padding: 12px; margin: 8px 0; font-size: 12px; }
1001
+ code[class*="language-"] { font-size: 12px; }
1002
+ </style>
1003
+ </head>
1004
+ <body class="m-0 bg-white text-gray-900 font-sans leading-relaxed">
1005
+ <div class="max-w-[1120px] mx-auto px-5 pt-[30px] pb-20">
1006
+ <div class="border-b-2 border-gray-900 pb-3 mb-[18px] flex gap-3.5 items-start justify-between flex-wrap">
1007
+ <div class="min-w-[280px]">
1008
+ <div class="text-xl font-black">${escapeHtml(title)}</div>
1009
+ <div class="mt-2 text-xs text-gray-500">
1010
+ <span class="font-mono">${escapeHtml(modelLower)}</span>
1011
+ <span class="mx-2">·</span>
1012
+ <span>${escapeHtml(generatedAt)}</span>
1013
+ </div>
1014
+ </div>
1015
+ <div class="flex gap-2 flex-wrap items-center pt-0.5">${openApiLinks}</div>
1016
+ </div>
1017
+
1018
+ <div class="${calloutClass}">${tocHtml}</div>
1019
+
1020
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="ops">1. Operations</h2>
1021
+ <div class="overflow-x-auto">
1022
+ <table class="w-full border-collapse text-xs">
1023
+ <thead>
1024
+ <tr>
1025
+ <th class="${thClass}">Operation</th>
1026
+ <th class="${thClass}">Method</th>
1027
+ <th class="${thClass}">Path</th>
1028
+ <th class="${thClass}">Transport</th>
1029
+ <th class="${thClass}">Required Args</th>
1030
+ <th class="${thClass}">Response</th>
1031
+ <th class="${thClass}">Errors</th>
1032
+ <th class="${thClass}">Notes</th>
1033
+ </tr>
1034
+ </thead>
1035
+ <tbody>
1036
+ ${ops.map((o) => `
1037
+ <tr>
1038
+ <td class="${tdClass} font-mono">${escapeHtml(o.op)}</td>
1039
+ <td class="${tdClass} font-mono">${escapeHtml(o.method)}</td>
1040
+ <td class="${tdClass} font-mono">${escapeHtml(o.path)}</td>
1041
+ <td class="${tdClass}">${escapeHtml(o.transport)}</td>
1042
+ <td class="${tdClass} font-mono">${o.required.length > 0 ? escapeHtml(o.required.join(', ')) : '<span class="text-gray-400">none</span>'}</td>
1043
+ <td class="${tdClass}">${escapeHtml(o.responseDesc)}</td>
1044
+ <td class="${tdClass} font-mono">${escapeHtml(o.errors)}</td>
1045
+ <td class="${tdClass} text-gray-500">${escapeHtml(o.notes)}</td>
1046
+ </tr>
1047
+ `).join('')}
1048
+ </tbody>
1049
+ </table>
1050
+ </div>
1051
+
1052
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="transport">2. Transport</h2>
1053
+ <div class="${calloutClass}">
1054
+ <ul class="text-[13px]">${transportNotes.map((t) => '<li>' + escapeHtml(t) + '</li>').join('')}</ul>
1055
+ </div>
1056
+
1057
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="args-read">3. Read Args Reference</h2>
1058
+ <div class="grid grid-cols-2 gap-3.5 max-[920px]:grid-cols-1">
1059
+ <div>
1060
+ <h3 class="mt-3.5 mb-2 text-sm">3.1 Read operations</h3>
1061
+ <div class="${calloutClass}">
1062
+ ${jsonBlock(argsReferenceRead)}
1063
+ </div>
1064
+ </div>
1065
+ <div>
1066
+ <h3 class="mt-3.5 mb-2 text-sm">3.2 Rules</h3>
1067
+ <div class="${calloutClass}">
1068
+ <ul class="text-[13px]">
1069
+ <li><span class="font-mono">where</span> — filtering</li>
1070
+ <li><span class="font-mono">select</span> — field selection</li>
1071
+ <li><span class="font-mono">include</span> — relation loading</li>
1072
+ <li><span class="font-mono">omit</span> — exclude fields from response</li>
1073
+ <li><span class="font-mono">select</span> and <span class="font-mono">include</span> cannot be combined at the same level</li>
1074
+ <li><span class="font-mono">select</span> and <span class="font-mono">omit</span> cannot be combined at the same level</li>
1075
+ <li><span class="font-mono">orderBy</span>, <span class="font-mono">cursor</span>, <span class="font-mono">take</span>, <span class="font-mono">skip</span>, <span class="font-mono">distinct</span> — pagination and ordering</li>
1076
+ <li><span class="font-mono">groupBy</span>: <span class="font-mono">orderBy</span> is required when using <span class="font-mono">skip</span> or <span class="font-mono">take</span></li>
1077
+ </ul>
1078
+ </div>
1079
+ </div>
1080
+ </div>
1081
+
1082
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="args-write">4. Write Args Reference</h2>
1083
+ <div class="grid grid-cols-2 gap-3.5 max-[920px]:grid-cols-1">
1084
+ <div>
1085
+ <h3 class="mt-3.5 mb-2 text-sm">4.1 Write operations</h3>
1086
+ <div class="${calloutClass}">
1087
+ ${jsonBlock(argsReferenceWrite)}
1088
+ </div>
1089
+ </div>
1090
+ <div>
1091
+ <h3 class="mt-3.5 mb-2 text-sm">4.2 Field write contract</h3>
1092
+ <div class="overflow-x-auto">
1093
+ <table class="w-full border-collapse text-xs">
1094
+ <thead>
1095
+ <tr>
1096
+ <th class="${thClass}">Field</th>
1097
+ <th class="${thClass}">Type</th>
1098
+ <th class="${thClass}">Write Behavior</th>
1099
+ </tr>
1100
+ </thead>
1101
+ <tbody>
1102
+ ${writeFieldRows.map((r) => `
1103
+ <tr>
1104
+ <td class="${tdClass} font-mono">${escapeHtml(r.name)}</td>
1105
+ <td class="${tdClass} font-mono">${escapeHtml(r.type)}</td>
1106
+ <td class="${tdClass}">${escapeHtml(r.writeContract)}</td>
1107
+ </tr>
1108
+ `).join('')}
1109
+ </tbody>
1110
+ </table>
1111
+ </div>
1112
+ </div>
1113
+ </div>
1114
+
1115
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="where">5. where</h2>
1116
+
1117
+ <div class="grid grid-cols-2 gap-3.5 max-[920px]:grid-cols-1">
1118
+ <div>
1119
+ <h3 class="mt-3.5 mb-2 text-sm">5.1 Boolean composition</h3>
1120
+ <div class="${calloutClass}">
1121
+ ${jsonBlock(whereCoreShapes)}
1122
+ </div>
1123
+ </div>
1124
+ <div>
1125
+ <h3 class="mt-3.5 mb-2 text-sm">5.2 Example</h3>
1126
+ <div class="${calloutClass}">
1127
+ ${jsonBlock(whereExample)}
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+
1132
+ <h3 class="mt-3.5 mb-2 text-sm">5.3 Per-field filters</h3>
1133
+
1134
+ <table class="w-full border-collapse text-xs">
1135
+ <thead>
1136
+ <tr>
1137
+ <th class="${thClass}">Field</th>
1138
+ <th class="${thClass}">Kind</th>
1139
+ <th class="${thClass}">Type</th>
1140
+ <th class="${thClass}">Accepted filters</th>
1141
+ </tr>
1142
+ </thead>
1143
+ <tbody>${whereRows}</tbody>
1144
+ </table>
1145
+
1146
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="select-include">6. select, include & omit</h2>
1147
+ <div class="grid grid-cols-2 gap-3.5 max-[920px]:grid-cols-1">
1148
+ <div>
1149
+ <h3 class="mt-3.5 mb-2 text-sm">6.1 Rules</h3>
1150
+ <div class="${calloutClass}">
1151
+ <ul class="text-[13px]">${selectRules.map((r) => '<li>' + escapeHtml(r) + '</li>').join('')}</ul>
1152
+ </div>
1153
+ </div>
1154
+ <div>
1155
+ <h3 class="mt-3.5 mb-2 text-sm">6.2 Examples</h3>
1156
+ <div class="${calloutClass}">
1157
+ <div class="text-gray-500 text-xs mb-1">select</div>
1158
+ ${jsonBlock(selectExample)}
1159
+ <div class="text-gray-500 text-xs mb-1 mt-2">include</div>
1160
+ ${jsonBlock(includeExample)}
1161
+ <div class="text-gray-500 text-xs mb-1 mt-2">omit</div>
1162
+ ${jsonBlock(omitExample)}
1163
+ </div>
1164
+ </div>
1165
+ </div>
1166
+
1167
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="nested-writes">7. Nested Writes</h2>
1168
+ <div class="grid grid-cols-2 gap-3.5 max-[920px]:grid-cols-1">
1169
+ <div>
1170
+ <h3 class="mt-3.5 mb-2 text-sm">7.1 List relation operations</h3>
1171
+ <div class="${calloutClass}">
1172
+ ${listRelations.length > 0
1173
+ ? '<div class="text-xs text-gray-500 mb-2">Relations: ' + listRelations.map((f) => '<span class="font-mono">' + escapeHtml(f.name) + '</span> (' + escapeHtml(String(f.type)) + '[])').join(', ') + '</div>'
1174
+ : '<div class="text-xs text-gray-500 mb-2">This model has no list relations.</div>'}
1175
+ <table class="w-full border-collapse text-xs">
1176
+ <tbody>
1177
+ ${nestedWriteListOps.map((op) => '<tr><td class="py-1.5 pr-2 align-top font-mono border-b border-gray-200">' + escapeHtml(op.key) + '</td><td class="py-1.5 border-b border-gray-200">' + escapeHtml(op.desc) + '</td></tr>').join('')}
1178
+ </tbody>
1179
+ </table>
1180
+ </div>
1181
+ <div class="${calloutClass} mt-2 text-xs text-gray-500">Nested write shapes depend on the related model's schema. See the related model's API docs for valid create and connect inputs. These operations are not available in bulk write endpoints (createMany, updateMany).</div>
1182
+ </div>
1183
+ <div>
1184
+ <h3 class="mt-3.5 mb-2 text-sm">7.2 Single relation operations</h3>
1185
+ <div class="${calloutClass}">
1186
+ ${singleRelations.length > 0
1187
+ ? '<div class="text-xs text-gray-500 mb-2">Relations: ' + singleRelations.map((f) => '<span class="font-mono">' + escapeHtml(f.name) + '</span> (' + escapeHtml(String(f.type)) + ')').join(', ') + '</div>'
1188
+ : '<div class="text-xs text-gray-500 mb-2">This model has no single relations.</div>'}
1189
+ <table class="w-full border-collapse text-xs">
1190
+ <tbody>
1191
+ ${nestedWriteSingleOps.map((op) => '<tr><td class="py-1.5 pr-2 align-top font-mono border-b border-gray-200">' + escapeHtml(op.key) + '</td><td class="py-1.5 border-b border-gray-200">' + escapeHtml(op.desc) + '</td></tr>').join('')}
1192
+ </tbody>
1193
+ </table>
1194
+ </div>
1195
+ <div class="${calloutClass} mt-2 text-xs text-gray-500">Nested write shapes depend on the related model's schema. See the related model's API docs for valid create and connect inputs. These operations are not available in bulk write endpoints (createMany, updateMany).</div>
1196
+ </div>
1197
+ </div>
1198
+
1199
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="order">8. orderBy / cursor / distinct</h2>
1200
+ <div class="${calloutClass}">
1201
+ <ul class="text-[13px]">${orderRules.map((r) => '<li>' + escapeHtml(r) + '</li>').join('')}</ul>
1202
+ </div>
1203
+
1204
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="pagination">9. Pagination</h2>
1205
+ <div class="${calloutClass}">
1206
+ <ul class="text-[13px]">${paginationNotes.map((r) => '<li>' + escapeHtml(r) + '</li>').join('')}</ul>
1207
+ </div>
1208
+
1209
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="errors">10. Error Handling</h2>
1210
+ <div class="${calloutClass}">
1211
+ <div class="text-[13px] mb-2">All errors return JSON with a <span class="font-mono">message</span> field.</div>
1212
+ </div>
1213
+ <table class="w-full border-collapse text-xs mt-2">
1214
+ <thead>
1215
+ <tr>
1216
+ <th class="${thClass}">Status</th>
1217
+ <th class="${thClass}">Description</th>
1218
+ <th class="${thClass}">Common Causes</th>
1219
+ </tr>
1220
+ </thead>
1221
+ <tbody>
1222
+ ${errorRows.map((r) => `
1223
+ <tr>
1224
+ <td class="${tdClass} font-mono">${escapeHtml(r.status)}</td>
1225
+ <td class="${tdClass}">${escapeHtml(r.description)}</td>
1226
+ <td class="${tdClass} text-gray-500">${escapeHtml(r.causes)}</td>
1227
+ </tr>
1228
+ `).join('')}
1229
+ </tbody>
1230
+ </table>
1231
+
1232
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="examples">11. Examples</h2>
1233
+ <div class="grid grid-cols-2 gap-3.5 max-[920px]:grid-cols-1 example">
1234
+ <div>
1235
+ <h3 class="mt-3.5 mb-2 text-sm">11.1 GET — findMany</h3>
1236
+ ${codeBlock(findManyFetchExample)}
1237
+ </div>
1238
+ <div>
1239
+ <h3 class="mt-3.5 mb-2 text-sm">11.2 GET — findUnique</h3>
1240
+ ${findUniqueFetchExample
1241
+ ? codeBlock(findUniqueFetchExample)
1242
+ : noUniqueFieldNote}
1243
+ </div>
1244
+ <div>
1245
+ <h3 class="mt-3.5 mb-2 text-sm">11.3 POST — create</h3>
1246
+ ${codeBlock(createFetchExample)}
1247
+ </div>
1248
+ <div>
1249
+ <h3 class="mt-3.5 mb-2 text-sm">11.4 PUT — update</h3>
1250
+ ${updateFetchExample
1251
+ ? codeBlock(updateFetchExample)
1252
+ : noUniqueFieldNote}
1253
+ </div>
1254
+ <div>
1255
+ <h3 class="mt-3.5 mb-2 text-sm">11.5 DELETE — delete</h3>
1256
+ ${deleteFetchExample
1257
+ ? codeBlock(deleteFetchExample)
1258
+ : noUniqueFieldNote}
1259
+ </div>
1260
+ <div>
1261
+ <h3 class="mt-3.5 mb-2 text-sm">11.6 Guard variant header</h3>
1262
+ ${guardFetchExample
1263
+ ? codeBlock(guardFetchExample)
1264
+ : noUniqueFieldNote}
1265
+ </div>
1266
+ </div>
1267
+
1268
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="guard-shapes">12. Guard Shapes</h2>
1269
+ <div class="${calloutClass}">
1270
+ <ul class="text-[13px]">${guardShapeInfo.map((r) => '<li>' + r + '</li>').join('')}</ul>
1271
+ </div>
1272
+
1273
+ <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="runtime">13. Runtime Notes</h2>
1274
+ <div class="${calloutClass}">
1275
+ <ul class="text-[13px] [&>li]:mb-2">${runtimeNotes.map((r) => '<li>' + r + '</li>').join('')}</ul>
1276
+ </div>
1277
+
1278
+ </div>
1279
+ <script src="${PRISM_JS}"></script>
1280
+ <script src="${PRISM_JSON}"></script>
1281
+ </body>
1282
+ </html>`
1283
+
1284
+ return html
1285
+ }