prisma-generator-express 1.54.0 → 1.56.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 (44) hide show
  1. package/README.md +186 -11
  2. package/dist/constants.d.ts +2 -0
  3. package/dist/generators/generateFastifyHandler.js +3 -1
  4. package/dist/generators/generateFastifyHandler.js.map +1 -1
  5. package/dist/generators/generateHonoHandler.js +3 -1
  6. package/dist/generators/generateHonoHandler.js.map +1 -1
  7. package/dist/generators/generateOperationCore.d.ts +3 -0
  8. package/dist/generators/generateOperationCore.js +68 -39
  9. package/dist/generators/generateOperationCore.js.map +1 -1
  10. package/dist/generators/generateRouteConfigType.js +4 -1
  11. package/dist/generators/generateRouteConfigType.js.map +1 -1
  12. package/dist/generators/generateRouter.d.ts +4 -1
  13. package/dist/generators/generateRouter.js +20 -7
  14. package/dist/generators/generateRouter.js.map +1 -1
  15. package/dist/generators/generateRouterFastify.d.ts +4 -1
  16. package/dist/generators/generateRouterFastify.js +23 -9
  17. package/dist/generators/generateRouterFastify.js.map +1 -1
  18. package/dist/generators/generateRouterHono.d.ts +4 -1
  19. package/dist/generators/generateRouterHono.js +29 -16
  20. package/dist/generators/generateRouterHono.js.map +1 -1
  21. package/dist/generators/generateUnifiedScalarUI.d.ts +2 -1
  22. package/dist/generators/generateUnifiedScalarUI.js +13 -10
  23. package/dist/generators/generateUnifiedScalarUI.js.map +1 -1
  24. package/dist/index.js +42 -3
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/constants.ts +5 -1
  28. package/src/copy/autoIncludeRuntime.ts +60 -35
  29. package/src/copy/buildModelOpenApi.ts +144 -23
  30. package/src/copy/docsRenderer.ts +125 -98
  31. package/src/copy/operationRuntime.ts +94 -9
  32. package/src/copy/routeConfig.express.ts +8 -0
  33. package/src/copy/routeConfig.fastify.ts +8 -0
  34. package/src/copy/routeConfig.hono.ts +9 -1
  35. package/src/copy/routeConfig.ts +23 -5
  36. package/src/generators/generateFastifyHandler.ts +3 -1
  37. package/src/generators/generateHonoHandler.ts +3 -1
  38. package/src/generators/generateOperationCore.ts +84 -39
  39. package/src/generators/generateRouteConfigType.ts +5 -2
  40. package/src/generators/generateRouter.ts +24 -6
  41. package/src/generators/generateRouterFastify.ts +27 -8
  42. package/src/generators/generateRouterHono.ts +33 -15
  43. package/src/generators/generateUnifiedScalarUI.ts +15 -11
  44. package/src/index.ts +49 -7
@@ -1,4 +1,4 @@
1
- import type { RouteConfig } from './routeConfig'
1
+ import type { RouteConfig, WriteStrategy } from './routeConfig'
2
2
  import { OPERATION_DEFS, isOperationEnabled, READ_OPERATION_NAMES } from './operationDefinitions'
3
3
  import { getEnv, normalizePrefix } from './misc'
4
4
 
@@ -256,6 +256,41 @@ const OP_DETAIL_MAP: Record<string, OpDetail> = {
256
256
  },
257
257
  }
258
258
 
259
+ function detailForOp(opName: string, writeStrategy?: WriteStrategy): OpDetail {
260
+ const base = OP_DETAIL_MAP[opName]
261
+ if (!base) return base
262
+ if (writeStrategy === 'forceReturn') {
263
+ if (opName === 'createMany') {
264
+ return {
265
+ ...base,
266
+ optional: ['skipDuplicates', 'select', 'include', 'omit'],
267
+ responseDesc: 'Array of created records (201)',
268
+ supportsSelect: true,
269
+ supportsInclude: true,
270
+ supportsOmit: true,
271
+ notes: 'writeStrategy="forceReturn": silently invokes createManyAndReturn and returns the created records instead of { count }.',
272
+ }
273
+ }
274
+ if (opName === 'updateMany') {
275
+ return {
276
+ ...base,
277
+ optional: ['select', 'include', 'omit'],
278
+ responseDesc: 'Array of updated records',
279
+ supportsSelect: true,
280
+ supportsInclude: true,
281
+ supportsOmit: true,
282
+ notes: 'writeStrategy="forceReturn": silently invokes updateManyAndReturn and returns the updated records instead of { count }.',
283
+ }
284
+ }
285
+ }
286
+ return base
287
+ }
288
+
289
+ function isOpHiddenByStrategy(opName: string, writeStrategy?: WriteStrategy): boolean {
290
+ if (writeStrategy !== 'throwOnNonReturning') return false
291
+ return opName === 'createMany' || opName === 'updateMany'
292
+ }
293
+
259
294
  function exampleValue(ctx: DocsModelContext, fieldName: string): unknown {
260
295
  return ctx.exampleValues[fieldName] ?? 'example'
261
296
  }
@@ -409,46 +444,17 @@ function isEnumField(f: FieldMeta): boolean {
409
444
  function scalarFilterOperators(scalarType: string): string[] {
410
445
  if (scalarType === 'String') {
411
446
  return [
412
- 'equals',
413
- 'in',
414
- 'notIn',
415
- 'lt',
416
- 'lte',
417
- 'gt',
418
- 'gte',
419
- 'contains',
420
- 'startsWith',
421
- 'endsWith',
422
- 'mode',
423
- 'not',
447
+ 'equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte',
448
+ 'contains', 'startsWith', 'endsWith', 'mode', 'not',
424
449
  ]
425
450
  }
426
-
427
- if (
428
- scalarType === 'Int' ||
429
- scalarType === 'BigInt' ||
430
- scalarType === 'Float' ||
431
- scalarType === 'Decimal'
432
- ) {
451
+ if (scalarType === 'Int' || scalarType === 'BigInt' || scalarType === 'Float' || scalarType === 'Decimal') {
433
452
  return ['equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'not']
434
453
  }
435
-
436
- if (scalarType === 'DateTime') {
437
- return ['equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'not']
438
- }
439
-
440
- if (scalarType === 'Boolean') {
441
- return ['equals', 'not']
442
- }
443
-
444
- if (scalarType === 'Json') {
445
- return ['equals', 'path', 'string_contains', 'array_contains', 'not']
446
- }
447
-
448
- if (scalarType === 'Bytes') {
449
- return ['equals', 'in', 'notIn', 'not']
450
- }
451
-
454
+ if (scalarType === 'DateTime') return ['equals', 'in', 'notIn', 'lt', 'lte', 'gt', 'gte', 'not']
455
+ if (scalarType === 'Boolean') return ['equals', 'not']
456
+ if (scalarType === 'Json') return ['equals', 'path', 'string_contains', 'array_contains', 'not']
457
+ if (scalarType === 'Bytes') return ['equals', 'in', 'notIn', 'not']
452
458
  return ['equals', 'in', 'notIn', 'not']
453
459
  }
454
460
 
@@ -465,27 +471,11 @@ function whereFieldKind(f: FieldMeta): string {
465
471
 
466
472
  function whereFieldShape(f: FieldMeta): string {
467
473
  const kind = whereFieldKind(f)
468
-
469
- if (kind === 'relation-list') {
470
- return '{ some?: RelatedWhere, every?: RelatedWhere, none?: RelatedWhere }'
471
- }
472
-
473
- if (kind === 'relation-single') {
474
- return '{ is?: RelatedWhere, isNot?: RelatedWhere }'
475
- }
476
-
477
- if (kind === 'scalar-list') {
478
- return '{ has?, hasEvery?, hasSome?, isEmpty? }'
479
- }
480
-
481
- if (kind === 'scalar') {
482
- return 'scalar value OR filter object'
483
- }
484
-
485
- if (kind === 'enum') {
486
- return 'enum value OR enum filter'
487
- }
488
-
474
+ if (kind === 'relation-list') return '{ some?: RelatedWhere, every?: RelatedWhere, none?: RelatedWhere }'
475
+ if (kind === 'relation-single') return '{ is?: RelatedWhere, isNot?: RelatedWhere }'
476
+ if (kind === 'scalar-list') return '{ has?, hasEvery?, hasSome?, isEmpty? }'
477
+ if (kind === 'scalar') return 'scalar value OR filter object'
478
+ if (kind === 'enum') return 'enum value OR enum filter'
489
479
  return 'n/a'
490
480
  }
491
481
 
@@ -540,7 +530,12 @@ function buildFullPath(basePath: string, suffix: string): string {
540
530
  return basePath ? basePath + suffix : suffix
541
531
  }
542
532
 
543
- export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModelContext): string {
533
+ export function renderDocs(
534
+ modelName: string,
535
+ config: DocsConfig,
536
+ ctx: DocsModelContext,
537
+ writeStrategy?: WriteStrategy,
538
+ ): string {
544
539
  const title = config.docsTitle || modelName + ' API'
545
540
  const generatedAt = new Date().toISOString()
546
541
 
@@ -559,8 +554,9 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
559
554
 
560
555
  const getOps = OPERATION_DEFS
561
556
  .filter((d) => isOperationEnabled(config as Record<string, any>, d))
557
+ .filter((d) => !isOpHiddenByStrategy(d.name, writeStrategy))
562
558
  .map((d) => {
563
- const detail = OP_DETAIL_MAP[d.name]
559
+ const detail = detailForOp(d.name, writeStrategy)
564
560
  return {
565
561
  op: d.name,
566
562
  method: d.method.toUpperCase(),
@@ -627,12 +623,8 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
627
623
  if (firstStringField) {
628
624
  andClauses.push({ [firstStringField.name]: { contains: 'example', mode: 'insensitive' } })
629
625
  }
630
- if (andClauses.length > 0) {
631
- whereExample.AND = andClauses
632
- }
633
- if (firstBooleanField) {
634
- whereExample.OR = [{ [firstBooleanField.name]: { equals: true } }]
635
- }
626
+ if (andClauses.length > 0) whereExample.AND = andClauses
627
+ if (firstBooleanField) whereExample.OR = [{ [firstBooleanField.name]: { equals: true } }]
636
628
 
637
629
  const selectExample: any = {}
638
630
  for (const f of scalarFields.slice(0, 10)) selectExample[f.name] = true
@@ -644,9 +636,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
644
636
  const omitCandidates = scalarFields.filter((f) => !f.isId && !f.isUnique)
645
637
  for (const f of omitCandidates.slice(0, 3)) omitExample[f.name] = true
646
638
 
647
- const orderByField = firstUnique
648
- ? firstUnique.name
649
- : firstFilterFieldName
639
+ const orderByField = firstUnique ? firstUnique.name : firstFilterFieldName
650
640
 
651
641
  const findManyQueryArgs: any = {
652
642
  where: whereExample,
@@ -698,9 +688,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
698
688
  : null
699
689
  if (updateBodyExample) {
700
690
  const firstEditableString = scalarFields.find((sf) => sf.type === 'String' && !sf.isId)
701
- if (firstEditableString) {
702
- updateBodyExample.data[firstEditableString.name] = 'updated'
703
- }
691
+ if (firstEditableString) updateBodyExample.data[firstEditableString.name] = 'updated'
704
692
  }
705
693
 
706
694
  const updateFetchExample = updateBodyExample
@@ -746,11 +734,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
746
734
  } else {
747
735
  writeContract = 'Optional (nullable)'
748
736
  }
749
- return {
750
- name: f.name,
751
- type: describeFieldType(f),
752
- writeContract,
753
- }
737
+ return { name: f.name, type: describeFieldType(f), writeContract }
754
738
  })
755
739
 
756
740
  const argsReferenceRead = {
@@ -817,7 +801,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
817
801
  },
818
802
  }
819
803
 
820
- const argsReferenceWrite = {
804
+ const argsReferenceWriteBase: Record<string, Record<string, string>> = {
821
805
  create: {
822
806
  data: modelName + 'CreateInput (required)',
823
807
  select: 'Select',
@@ -867,19 +851,51 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
867
851
  include: 'Include',
868
852
  omit: 'Omit',
869
853
  },
870
- deleteMany: {
871
- where: 'WhereInput (required)',
872
- },
854
+ deleteMany: { where: 'WhereInput (required)' },
873
855
  }
874
856
 
857
+ const argsReferenceWrite: Record<string, Record<string, string>> = (() => {
858
+ if (writeStrategy === 'throwOnNonReturning') {
859
+ const { createMany, updateMany, ...rest } = argsReferenceWriteBase
860
+ return rest
861
+ }
862
+ if (writeStrategy === 'forceReturn') {
863
+ return {
864
+ ...argsReferenceWriteBase,
865
+ createMany: {
866
+ data: modelName + 'CreateManyInput[] (required, scalar-only)',
867
+ skipDuplicates: 'boolean',
868
+ select: 'Select',
869
+ include: 'Include',
870
+ omit: 'Omit',
871
+ },
872
+ updateMany: {
873
+ where: 'WhereInput (required)',
874
+ data: modelName + 'UpdateManyMutationInput (required, scalar-only)',
875
+ select: 'Select',
876
+ include: 'Include',
877
+ omit: 'Omit',
878
+ },
879
+ }
880
+ }
881
+ return argsReferenceWriteBase
882
+ })()
883
+
884
+ const batchMutationNote =
885
+ writeStrategy === 'forceReturn'
886
+ ? 'Batch mutations: deleteMany returns { count }. createMany and updateMany silently invoke their returning counterparts and return arrays of records. Batch data inputs are scalar-only — nested relation writes are not supported.'
887
+ : writeStrategy === 'throwOnNonReturning'
888
+ ? 'Batch mutations: deleteMany returns { count }. createMany and updateMany are disabled and return 501 if called directly; use the /many/return variants. Batch data inputs are scalar-only — nested relation writes are not supported.'
889
+ : 'Batch mutations (createMany, updateMany, deleteMany) return { count }. Batch data inputs are scalar-only — nested relation writes are not supported.'
890
+
875
891
  const transportNotes = [
876
892
  'GET endpoints: Prisma args as JSON-encoded query parameter strings via encodeQueryParams.',
877
893
  'POST/PUT/DELETE/PATCH endpoints: Prisma args as JSON request body. Body must be a JSON object.',
878
894
  'POST read endpoints: All read operations also accept POST with the same args as JSON body instead of query params. Use when query parameters exceed URL length limits. findMany uses POST /read, all others use the same path as GET.',
879
895
  'findManyPaginated returns { data, total, hasMore }. hasMore is reliable for forward offset pagination only.',
880
- 'Batch mutations (createMany, updateMany, deleteMany) return { count }. Batch data inputs are scalar-only — nested relation writes are not supported.',
896
+ batchMutationNote,
881
897
  'findUnique and findFirst return null (not 404) when no record matches. Use the OrThrow variants for 404 behavior.',
882
- 'createManyAndReturn requires Prisma 5.14.0+, updateManyAndReturn requires Prisma 6.2.0+. Both are limited to PostgreSQL, CockroachDB, and SQLite.',
898
+ 'createManyAndReturn requires Prisma 5.14.0+, updateManyAndReturn requires Prisma 6.2.0+. Both are limited to PostgreSQL/CockroachDB/SQLite.',
883
899
  ]
884
900
 
885
901
  const errorRows = [
@@ -887,8 +903,8 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
887
903
  { status: '403', description: 'Forbidden', causes: 'Guard policy rejected the operation.' },
888
904
  { status: '404', description: 'Not found', causes: 'Record not found. Only from OrThrow operations, update, and delete.' },
889
905
  { status: '409', description: 'Conflict', causes: 'Unique constraint violation on create/update/upsert, or transaction conflict (e.g. in findManyPaginated).' },
890
- { status: '500', description: 'Internal server error', causes: 'Database error, table/column missing, raw query failure, or unhandled error.' },
891
- { status: '501', description: 'Not implemented', causes: 'Database provider does not support the requested feature.' },
906
+ { status: '500', description: 'Internal server error', causes: 'Database error, table/column missing, raw query failure, unhandled error, or findManyPaginatedMode="transaction" without transaction support on the Prisma client.' },
907
+ { status: '501', description: 'Not implemented', causes: 'Database provider does not support the requested feature, or writeStrategy disabled the endpoint.' },
892
908
  { status: '503', description: 'Service unavailable', causes: 'Database connection pool timeout.' },
893
909
  ]
894
910
 
@@ -903,6 +919,15 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
903
919
  '<a class="' + chipClass + '" href="?ui=yaml">YAML</a>' +
904
920
  (hasPlayground ? '<a class="' + chipClass + '" href="?ui=playground">Playground</a>' : '')
905
921
 
922
+ const writeStrategyBanner = !writeStrategy || writeStrategy === 'regular'
923
+ ? ''
924
+ : '<div class="mt-3 p-3 rounded-xl border border-amber-300 bg-amber-50 text-xs">' +
925
+ '<strong>writeStrategy = ' + escapeHtml(writeStrategy) + '.</strong> ' +
926
+ (writeStrategy === 'throwOnNonReturning'
927
+ ? 'POST /many (createMany) and PUT /many (updateMany) are disabled and respond with 501. Use the /many/return variants.'
928
+ : 'POST /many silently invokes createManyAndReturn; PUT /many silently invokes updateManyAndReturn. Both return arrays of records instead of { count } and accept select/include/omit.') +
929
+ '</div>'
930
+
906
931
  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>'
907
932
 
908
933
  const whereRows = ctx.fields.map((f) => {
@@ -997,12 +1022,19 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
997
1022
  'Forced values (literals instead of true) are injected server-side and cannot be overridden by the client.',
998
1023
  ]
999
1024
 
1025
+ const writeStrategyNotes = !writeStrategy || writeStrategy === 'regular'
1026
+ ? []
1027
+ : writeStrategy === 'throwOnNonReturning'
1028
+ ? ['<strong>writeStrategy="throwOnNonReturning":</strong> the createMany (POST /many) and updateMany (PUT /many) endpoints are disabled and return 501. Use the corresponding <span class="font-mono">/many/return</span> endpoints instead.']
1029
+ : ['<strong>writeStrategy="forceReturn":</strong> the createMany (POST /many) and updateMany (PUT /many) endpoints silently invoke createManyAndReturn and updateManyAndReturn respectively. They return arrays of records instead of <span class="font-mono">{ count }</span>, and their request bodies accept <span class="font-mono">select</span>, <span class="font-mono">include</span>, and <span class="font-mono">omit</span>.']
1030
+
1000
1031
  const runtimeNotes = [
1001
1032
  '<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.',
1002
1033
  '<strong>POST read endpoints:</strong> All read operations accept POST as an alternative transport. The request body is a plain JSON object with the same argument structure as the GET query params — no JSON-string encoding needed. POST reads use native JSON types (numbers, booleans, objects) directly. findMany POST read is at <span class="font-mono">/read</span>; all other read operations use the same path as their GET counterpart. Disable with <span class="font-mono">disablePostReads: true</span> in route config.',
1003
1034
  '<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.',
1004
1035
  '<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.',
1005
- '<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. When a guard shape is configured, the data and count queries run in parallel without a transaction to keep the guard wrapper in scope, so atomicity is not guaranteed in that mode.',
1036
+ '<strong>Paginated query execution:</strong> findManyPaginated execution is controlled by the generator option <span class="font-mono">findManyPaginatedMode</span>. The default <span class="font-mono">"promiseAll"</span> runs data and count with <span class="font-mono">Promise.all</span> faster, but not atomic under concurrent writes (data and total may be slightly inconsistent). <span class="font-mono">"transaction"</span> runs data and count inside an interactive transaction and throws <span class="font-mono">500</span> if the Prisma client does not support transactions. There is no implicit fallback in transaction mode.',
1037
+ '<strong>Materialized count source:</strong> findManyPaginated supports endpoint-level <span class="font-mono">pagination.countSource.type="materializedView"</span> for counts driven by a materialized view. The view query is used only when the request has no dynamic <span class="font-mono">where</span>, no <span class="font-mono">distinct</span>, and no guard shape — any of those falls back to the delegate count to keep the total consistent with the filtered data. The generated SQL uses PostgreSQL-style <span class="font-mono">$N</span> placeholders (PostgreSQL and CockroachDB). The optional <span class="font-mono">countSource.where</span> field supports flat equality and <span class="font-mono">null</span> only — no operators, no nested objects.',
1006
1038
  '<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. When a guard shape is configured alongside distinct, the total also falls back to a non-distinct count to avoid imposing the public read shape on the internal counting query. The hasMore value is affected accordingly.',
1007
1039
  '<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.',
1008
1040
  '<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.',
@@ -1011,6 +1043,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
1011
1043
  '<strong>Bulk write constraints:</strong> createMany, createManyAndReturn, updateMany, and updateManyAndReturn accept scalar-only data inputs. Nested relation writes are not supported in these operations.',
1012
1044
  '<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.',
1013
1045
  '<strong>omit compatibility:</strong> The omit parameter requires Prisma 6.2.0+. On versions 6.0.x–6.1.x, requests using omit return 400.',
1046
+ ...writeStrategyNotes,
1014
1047
  ]
1015
1048
 
1016
1049
  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>'
@@ -1048,6 +1081,8 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
1048
1081
  <div class="flex gap-2 flex-wrap items-center pt-0.5">${openApiLinks}</div>
1049
1082
  </div>
1050
1083
 
1084
+ ${writeStrategyBanner}
1085
+
1051
1086
  <div class="${calloutClass}">${tocHtml}</div>
1052
1087
 
1053
1088
  <h2 class="mt-[18px] mb-2 text-base border-t border-gray-200 pt-3.5" id="ops">1. Operations</h2>
@@ -1274,9 +1309,7 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
1274
1309
  </div>
1275
1310
  <div>
1276
1311
  <h3 class="mt-3.5 mb-2 text-sm">11.3 GET — findUnique</h3>
1277
- ${findUniqueFetchExample
1278
- ? codeBlock(findUniqueFetchExample)
1279
- : noUniqueFieldNote}
1312
+ ${findUniqueFetchExample ? codeBlock(findUniqueFetchExample) : noUniqueFieldNote}
1280
1313
  </div>
1281
1314
  <div>
1282
1315
  <h3 class="mt-3.5 mb-2 text-sm">11.4 POST — create</h3>
@@ -1284,21 +1317,15 @@ export function renderDocs(modelName: string, config: DocsConfig, ctx: DocsModel
1284
1317
  </div>
1285
1318
  <div>
1286
1319
  <h3 class="mt-3.5 mb-2 text-sm">11.5 PUT — update</h3>
1287
- ${updateFetchExample
1288
- ? codeBlock(updateFetchExample)
1289
- : noUniqueFieldNote}
1320
+ ${updateFetchExample ? codeBlock(updateFetchExample) : noUniqueFieldNote}
1290
1321
  </div>
1291
1322
  <div>
1292
1323
  <h3 class="mt-3.5 mb-2 text-sm">11.6 DELETE — delete</h3>
1293
- ${deleteFetchExample
1294
- ? codeBlock(deleteFetchExample)
1295
- : noUniqueFieldNote}
1324
+ ${deleteFetchExample ? codeBlock(deleteFetchExample) : noUniqueFieldNote}
1296
1325
  </div>
1297
1326
  <div>
1298
1327
  <h3 class="mt-3.5 mb-2 text-sm">11.7 Guard variant header</h3>
1299
- ${guardFetchExample
1300
- ? codeBlock(guardFetchExample)
1301
- : noUniqueFieldNote}
1328
+ ${guardFetchExample ? codeBlock(guardFetchExample) : noUniqueFieldNote}
1302
1329
  </div>
1303
1330
  </div>
1304
1331
 
@@ -5,6 +5,9 @@ import type {
5
5
  ProgressiveStageResult,
6
6
  ProgressiveStageContext,
7
7
  ProgressiveStage,
8
+ PaginationConfig,
9
+ PaginationCountSource,
10
+ FindManyPaginatedMode,
8
11
  } from './routeConfig'
9
12
 
10
13
  export type {
@@ -13,12 +16,9 @@ export type {
13
16
  ProgressiveStageResult,
14
17
  ProgressiveStageContext,
15
18
  ProgressiveStage,
16
- }
17
-
18
- export interface PaginationConfig {
19
- defaultLimit?: number
20
- maxLimit?: number
21
- distinctCountLimit?: number
19
+ PaginationConfig,
20
+ PaginationCountSource,
21
+ FindManyPaginatedMode,
22
22
  }
23
23
 
24
24
  export interface OperationContext {
@@ -30,6 +30,7 @@ export interface OperationContext {
30
30
  guardShape?: Record<string, unknown>
31
31
  guardCaller?: string
32
32
  paginationConfig?: PaginationConfig
33
+ findManyPaginatedMode?: FindManyPaginatedMode
33
34
  }
34
35
 
35
36
  export type PrismaDelegate = {
@@ -58,8 +59,14 @@ export type PrismaClientLike = {
58
59
  $transaction?: <T>(fn: (tx: PrismaClientLike) => Promise<T>) => Promise<T>
59
60
  }
60
61
 
62
+ type PrismaRawClient = {
63
+ $queryRawUnsafe?: <T = unknown>(sql: string, ...values: unknown[]) => Promise<T>
64
+ }
65
+
61
66
  export const DISTINCT_COUNT_LIMIT = 100000
62
67
 
68
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
69
+
63
70
  export class HttpError extends Error {
64
71
  status: number
65
72
  constructor(status: number, message: string) {
@@ -254,6 +261,14 @@ export function applyPaginationLimits(
254
261
  return result
255
262
  }
256
263
 
264
+ export function mergePaginationConfig(
265
+ base: PaginationConfig | undefined,
266
+ override: Partial<PaginationConfig> | undefined,
267
+ ): PaginationConfig | undefined {
268
+ if (!base && !override) return undefined
269
+ return { ...(base ?? {}), ...(override ?? {}) }
270
+ }
271
+
257
272
  export function normalizeDistinct(value: unknown): string[] {
258
273
  if (typeof value === 'string') return [value]
259
274
  if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
@@ -309,13 +324,85 @@ export function buildCountShape(
309
324
  return result
310
325
  }
311
326
 
327
+ function quoteIdent(name: string): string {
328
+ if (!IDENT_RE.test(name)) {
329
+ throw new HttpError(400, 'invalid identifier: ' + name)
330
+ }
331
+ return '"' + name.replace(/"/g, '""') + '"'
332
+ }
333
+
334
+ function buildMaterializedCountFqn(
335
+ source: Extract<PaginationCountSource, { type: 'materializedView' }>,
336
+ ): string {
337
+ return source.schema
338
+ ? quoteIdent(source.schema) + '.' + quoteIdent(source.relation)
339
+ : quoteIdent(source.relation)
340
+ }
341
+
342
+ function buildMaterializedCountWhere(
343
+ where: Record<string, unknown> | undefined,
344
+ ): { sql: string; values: unknown[] } {
345
+ if (!where || Object.keys(where).length === 0) {
346
+ return { sql: '', values: [] }
347
+ }
348
+ const values: unknown[] = []
349
+ const clauses: string[] = []
350
+ for (const [key, value] of Object.entries(where)) {
351
+ if (value === null) {
352
+ clauses.push(quoteIdent(key) + ' IS NULL')
353
+ continue
354
+ }
355
+ values.push(value)
356
+ clauses.push(quoteIdent(key) + ' = $' + values.length)
357
+ }
358
+ return { sql: ' WHERE ' + clauses.join(' AND '), values }
359
+ }
360
+
361
+ export async function countFromMaterializedView(
362
+ client: unknown,
363
+ source: Extract<PaginationCountSource, { type: 'materializedView' }>,
364
+ ): Promise<number> {
365
+ const raw = client as PrismaRawClient
366
+ if (typeof raw.$queryRawUnsafe !== 'function') {
367
+ throw new HttpError(500, 'Materialized count source requires $queryRawUnsafe on the Prisma client')
368
+ }
369
+ const column = source.column ?? 'total'
370
+ const where = buildMaterializedCountWhere(source.where)
371
+ const sql =
372
+ 'SELECT ' +
373
+ quoteIdent(column) +
374
+ ' AS "total" FROM ' +
375
+ buildMaterializedCountFqn(source) +
376
+ where.sql +
377
+ ' LIMIT 1'
378
+ const rows = await raw.$queryRawUnsafe<Array<{ total: unknown }>>(sql, ...where.values)
379
+ const value = rows[0]?.total
380
+ const total = Number(value)
381
+ if (!Number.isFinite(total)) {
382
+ throw new HttpError(500, 'Materialized count source did not return a numeric total')
383
+ }
384
+ return Math.trunc(total)
385
+ }
386
+
312
387
  export async function countForPagination(
313
388
  delegate: PrismaDelegate,
314
389
  query: Record<string, unknown>,
315
390
  shape: Record<string, unknown> | undefined,
316
391
  caller: string | undefined,
317
392
  distinctCountLimit?: number,
393
+ countSource?: PaginationCountSource,
394
+ rawClient?: unknown,
318
395
  ): Promise<number> {
396
+ if (
397
+ countSource &&
398
+ countSource.type === 'materializedView' &&
399
+ !shape &&
400
+ !query.where &&
401
+ !query.distinct
402
+ ) {
403
+ return countFromMaterializedView(rawClient ?? delegate, countSource)
404
+ }
405
+
319
406
  const distinctFields = normalizeDistinct(query.distinct)
320
407
  const hasDistinct = distinctFields.length > 0
321
408
  const effectiveLimit = distinctCountLimit ?? DISTINCT_COUNT_LIMIT
@@ -333,9 +420,7 @@ export async function countForPagination(
333
420
  return (await delegate.count(countArgs)) as number
334
421
  }
335
422
 
336
- if (hasDistinct && shape) {
337
- return runCount()
338
- }
423
+ if (hasDistinct && shape) return runCount()
339
424
 
340
425
  if (hasDistinct) {
341
426
  const selectField = distinctFields[0]
@@ -7,12 +7,20 @@ import type {
7
7
  QueryBuilderConfig,
8
8
  OpenApiServerConfig,
9
9
  OpenApiSecuritySchemeConfig,
10
+ WriteStrategy,
11
+ FindManyPaginatedMode,
12
+ PaginationConfig,
13
+ PaginationCountSource,
10
14
  } from './routeConfig'
11
15
 
12
16
  export type {
13
17
  QueryBuilderConfig,
14
18
  OpenApiServerConfig,
15
19
  OpenApiSecuritySchemeConfig,
20
+ WriteStrategy,
21
+ FindManyPaginatedMode,
22
+ PaginationConfig,
23
+ PaginationCountSource,
16
24
  }
17
25
 
18
26
  export type {
@@ -5,12 +5,20 @@ import type {
5
5
  QueryBuilderConfig,
6
6
  OpenApiServerConfig,
7
7
  OpenApiSecuritySchemeConfig,
8
+ WriteStrategy,
9
+ FindManyPaginatedMode,
10
+ PaginationConfig,
11
+ PaginationCountSource,
8
12
  } from './routeConfig'
9
13
 
10
14
  export type {
11
15
  QueryBuilderConfig,
12
16
  OpenApiServerConfig,
13
17
  OpenApiSecuritySchemeConfig,
18
+ WriteStrategy,
19
+ FindManyPaginatedMode,
20
+ PaginationConfig,
21
+ PaginationCountSource,
14
22
  }
15
23
 
16
24
  export type FastifyHookHandler = (
@@ -5,12 +5,20 @@ import type {
5
5
  QueryBuilderConfig,
6
6
  OpenApiServerConfig,
7
7
  OpenApiSecuritySchemeConfig,
8
+ WriteStrategy,
9
+ FindManyPaginatedMode,
10
+ PaginationConfig,
11
+ PaginationCountSource,
8
12
  } from './routeConfig'
9
13
 
10
14
  export type {
11
15
  QueryBuilderConfig,
12
16
  OpenApiServerConfig,
13
17
  OpenApiSecuritySchemeConfig,
18
+ WriteStrategy,
19
+ FindManyPaginatedMode,
20
+ PaginationConfig,
21
+ PaginationCountSource,
14
22
  }
15
23
 
16
24
  export type HonoEnvBase = {
@@ -24,7 +32,7 @@ export type HonoInternalVariables = {
24
32
  sqlite?: unknown
25
33
  parsedQuery?: Record<string, unknown>
26
34
  body?: unknown
27
- routeConfig?: { pagination?: unknown }
35
+ routeConfig?: { pagination?: PaginationConfig }
28
36
  guardShape?: Record<string, unknown>
29
37
  guardCaller?: string
30
38
  resultData?: unknown
@@ -20,6 +20,27 @@ export interface OpenApiSecuritySchemeConfig {
20
20
  description?: string
21
21
  }
22
22
 
23
+ export type WriteStrategy = 'regular' | 'throwOnNonReturning' | 'forceReturn'
24
+
25
+ export type FindManyPaginatedMode = 'transaction' | 'promiseAll'
26
+
27
+ export type PaginationCountSource =
28
+ | { type?: 'delegate' }
29
+ | {
30
+ type: 'materializedView'
31
+ relation: string
32
+ schema?: string
33
+ column?: string
34
+ where?: Record<string, unknown>
35
+ }
36
+
37
+ export interface PaginationConfig {
38
+ defaultLimit?: number
39
+ maxLimit?: number
40
+ distinctCountLimit?: number
41
+ countSource?: PaginationCountSource
42
+ }
43
+
23
44
  export type ProgressivePatch = {
24
45
  key: string
25
46
  value: unknown
@@ -70,6 +91,7 @@ export interface BaseOperationConfig<HookHandler, TShape = Record<string, unknow
70
91
  before?: HookHandler[]
71
92
  after?: HookHandler[]
72
93
  shape?: TShape
94
+ pagination?: Partial<PaginationConfig>
73
95
  }
74
96
 
75
97
  export interface BaseRouteConfig<
@@ -97,11 +119,7 @@ export interface BaseRouteConfig<
97
119
  }
98
120
  resolveContext?: (request: RequestType) => TCtx | Promise<TCtx>
99
121
  queryBuilder?: QueryBuilderConfig | false
100
- pagination?: {
101
- defaultLimit?: number
102
- maxLimit?: number
103
- distinctCountLimit?: number
104
- }
122
+ pagination?: PaginationConfig
105
123
  findUnique?: BaseOperationConfig<HookHandler, TShape>
106
124
  findUniqueOrThrow?: BaseOperationConfig<HookHandler, TShape>
107
125
  findFirst?: BaseOperationConfig<HookHandler, TShape>