prisma-generator-express 1.56.4 → 1.58.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 +286 -29
  2. package/dist/copy/misc.js +25 -6
  3. package/dist/copy/misc.js.map +1 -1
  4. package/dist/generators/generateFastifyHandler.js +11 -0
  5. package/dist/generators/generateFastifyHandler.js.map +1 -1
  6. package/dist/generators/generateHonoHandler.js +14 -20
  7. package/dist/generators/generateHonoHandler.js.map +1 -1
  8. package/dist/generators/generateImportPrismaStatement.js +43 -0
  9. package/dist/generators/generateImportPrismaStatement.js.map +1 -1
  10. package/dist/generators/generateOperationCore.js +58 -17
  11. package/dist/generators/generateOperationCore.js.map +1 -1
  12. package/dist/generators/generateRouteConfigType.js +44 -15
  13. package/dist/generators/generateRouteConfigType.js.map +1 -1
  14. package/dist/generators/generateRouter.d.ts +2 -1
  15. package/dist/generators/generateRouter.js +60 -34
  16. package/dist/generators/generateRouter.js.map +1 -1
  17. package/dist/generators/generateRouterFastify.d.ts +2 -1
  18. package/dist/generators/generateRouterFastify.js +238 -193
  19. package/dist/generators/generateRouterFastify.js.map +1 -1
  20. package/dist/generators/generateRouterHono.d.ts +2 -1
  21. package/dist/generators/generateRouterHono.js +124 -89
  22. package/dist/generators/generateRouterHono.js.map +1 -1
  23. package/dist/index.js +22 -4
  24. package/dist/index.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/copy/autoIncludeRuntime.ts +9 -5
  27. package/src/copy/buildModelOpenApi.ts +96 -0
  28. package/src/copy/docsRenderer.ts +577 -174
  29. package/src/copy/materializedRouter.ts +40 -1
  30. package/src/copy/misc.ts +23 -6
  31. package/src/copy/operationDefinitions.ts +10 -0
  32. package/src/copy/operationRuntime.ts +28 -9
  33. package/src/copy/routeConfig.express.ts +9 -9
  34. package/src/copy/routeConfig.hono.ts +63 -5
  35. package/src/copy/routeConfig.ts +44 -20
  36. package/src/generators/generateFastifyHandler.ts +12 -0
  37. package/src/generators/generateHonoHandler.ts +15 -20
  38. package/src/generators/generateImportPrismaStatement.ts +13 -0
  39. package/src/generators/generateOperationCore.ts +58 -17
  40. package/src/generators/generateRouteConfigType.ts +52 -17
  41. package/src/generators/generateRouter.ts +61 -33
  42. package/src/generators/generateRouterFastify.ts +239 -192
  43. package/src/generators/generateRouterHono.ts +125 -88
  44. package/src/index.ts +25 -5
@@ -48,6 +48,8 @@ type MaterializedRouterOptions = {
48
48
 
49
49
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
50
50
 
51
+ export const forbidden = (message: string): HttpError => new HttpError(403, message)
52
+
51
53
  const quoteIdent = (name: string): string => {
52
54
  if (!IDENT_RE.test(name))
53
55
  throw new HttpError(400, 'invalid identifier: ' + name)
@@ -144,9 +146,46 @@ const enforceAllowlist = (orderBy: OrderByDef, allowed?: string[]): void => {
144
146
  }
145
147
  }
146
148
 
149
+ const validateViewIdentifier = (
150
+ viewName: string,
151
+ fieldName: string,
152
+ value: string,
153
+ ): void => {
154
+ if (!IDENT_RE.test(value)) {
155
+ throw new Error(
156
+ 'materializedViewsRouter: invalid ' + fieldName + ' identifier for view "' +
157
+ viewName + '": ' + value,
158
+ )
159
+ }
160
+ }
161
+
162
+ const validateViewConfig = (viewName: string, def: ViewDef): void => {
163
+ validateViewIdentifier(viewName, 'relation', def.relation)
164
+ if (def.schema) validateViewIdentifier(viewName, 'schema', def.schema)
165
+ const ob = def.orderBy
166
+ if (typeof ob === 'string') validateViewIdentifier(viewName, 'orderBy', ob)
167
+ else if (ob) validateViewIdentifier(viewName, 'orderBy.field', ob.field)
168
+ if (def.allowedOrderBy) {
169
+ for (const field of def.allowedOrderBy) {
170
+ validateViewIdentifier(viewName, 'allowedOrderBy[]', field)
171
+ }
172
+ }
173
+ if (!def.allowedOrderBy && !def.orderBy) {
174
+ console.warn(
175
+ '[materializedViewsRouter] view "' + viewName +
176
+ '" has neither `orderBy` nor `allowedOrderBy` set. ' +
177
+ 'Clients can sort by any valid identifier, which may cause full table scans.',
178
+ )
179
+ }
180
+ }
181
+
147
182
  export const materializedViewsRouter = (
148
183
  opts: MaterializedRouterOptions,
149
184
  ): Router => {
185
+ for (const [name, def] of Object.entries(opts.views)) {
186
+ validateViewConfig(name, def)
187
+ }
188
+
150
189
  const router = express.Router()
151
190
  const basePath = normalizeBasePath(opts.basePath)
152
191
  const defaultLimit = opts.defaultLimit ?? 50
@@ -230,4 +269,4 @@ export const materializedViewsRouter = (
230
269
  )
231
270
 
232
271
  return router
233
- }
272
+ }
package/src/copy/misc.ts CHANGED
@@ -17,15 +17,32 @@ export function isSafeKey(key: string): boolean {
17
17
 
18
18
  export function sanitizeKeys<T>(value: T): T {
19
19
  if (Array.isArray(value)) {
20
- return value.map(sanitizeKeys) as T
20
+ let mutated = false
21
+ const out: unknown[] = new Array(value.length)
22
+ for (let i = 0; i < value.length; i++) {
23
+ const sanitized = sanitizeKeys(value[i])
24
+ if (sanitized !== value[i]) mutated = true
25
+ out[i] = sanitized
26
+ }
27
+ return (mutated ? out : value) as T
21
28
  }
22
29
  if (isPlainObject(value)) {
23
- const result: Record<string, unknown> = {}
24
- for (const key of Object.keys(value)) {
25
- if (!isSafeKey(key)) continue
26
- result[key] = sanitizeKeys((value as Record<string, unknown>)[key])
30
+ const keys = Object.keys(value)
31
+ let hasUnsafe = false
32
+ let childrenMutated = false
33
+ const sanitizedChildren: Record<string, unknown> = {}
34
+ for (const key of keys) {
35
+ if (!isSafeKey(key)) {
36
+ hasUnsafe = true
37
+ continue
38
+ }
39
+ const original = (value as Record<string, unknown>)[key]
40
+ const sanitized = sanitizeKeys(original)
41
+ if (sanitized !== original) childrenMutated = true
42
+ sanitizedChildren[key] = sanitized
27
43
  }
28
- return result as T
44
+ if (!hasUnsafe && !childrenMutated) return value
45
+ return sanitizedChildren as T
29
46
  }
30
47
  return value
31
48
  }
@@ -5,6 +5,7 @@ export interface OperationDef {
5
5
  method: HttpMethod
6
6
  pathSuffix: string
7
7
  configKey: string
8
+ excludeFromEnableAll?: boolean
8
9
  }
9
10
 
10
11
  export const OPERATION_DEFS: OperationDef[] = [
@@ -86,6 +87,13 @@ export const OPERATION_DEFS: OperationDef[] = [
86
87
  pathSuffix: '/groupby',
87
88
  configKey: 'groupBy',
88
89
  },
90
+ {
91
+ name: 'updateEach',
92
+ method: 'post',
93
+ pathSuffix: '/each',
94
+ configKey: 'updateEach',
95
+ excludeFromEnableAll: true,
96
+ },
89
97
  ]
90
98
 
91
99
  export const READ_OPERATION_NAMES = new Set([
@@ -110,5 +118,7 @@ export function isOperationEnabled(
110
118
  config: Record<string, any>,
111
119
  def: OperationDef,
112
120
  ): boolean {
121
+ if (config[def.configKey] === false) return false
122
+ if (def.excludeFromEnableAll) return !!config[def.configKey]
113
123
  return !!(config.enableAll || config[def.configKey])
114
124
  }
@@ -104,6 +104,7 @@ const PRISMA_ERROR_MAP: Record<string, { status: number; message: string }> = {
104
104
  P2024: { status: 503, message: 'Connection pool timeout' },
105
105
  P2025: { status: 404, message: 'Record not found' },
106
106
  P2026: { status: 501, message: 'Feature not supported by the current database provider' },
107
+ P2027: { status: 400, message: 'Multiple errors occurred during transaction execution' },
107
108
  P2028: { status: 500, message: 'Transaction API error' },
108
109
  P2030: { status: 400, message: 'Cannot find a fulltext index for the search' },
109
110
  P2033: { status: 400, message: 'Number out of range for the field type' },
@@ -122,9 +123,15 @@ function asErrorShape(error: unknown): ErrorShape {
122
123
  return {}
123
124
  }
124
125
 
126
+ function isProduction(): boolean {
127
+ return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production'
128
+ }
129
+
125
130
  export function mapError(error: unknown): HttpError {
126
131
  if (error instanceof HttpError) return error
127
132
  const e = asErrorShape(error)
133
+ const isProd = isProduction()
134
+
128
135
  if (e.name === 'ShapeError') return new HttpError(400, e.message || 'Shape validation failed')
129
136
  if (e.name === 'CallerError') return new HttpError(400, e.message || 'Caller validation failed')
130
137
  if (e.name === 'PolicyError') return new HttpError(403, e.message || 'Policy denied')
@@ -139,24 +146,34 @@ export function mapError(error: unknown): HttpError {
139
146
  const mapped = PRISMA_ERROR_MAP[e.code]
140
147
  if (mapped) {
141
148
  const detail = e.message
142
- return new HttpError(mapped.status, detail ? mapped.message + ': ' + detail : mapped.message)
149
+ const shouldStripDetail = isProd && mapped.status >= 500
150
+ return new HttpError(
151
+ mapped.status,
152
+ !shouldStripDetail && detail ? mapped.message + ': ' + detail : mapped.message,
153
+ )
143
154
  }
144
155
  if (e.code.startsWith('P')) {
145
156
  const msg = e.message || 'Database operation failed'
146
157
  console.warn('[prisma-generator-express] Unmapped Prisma error code:', e.code, msg)
147
- return new HttpError(500, msg)
158
+ return new HttpError(500, isProd ? 'Internal server error' : msg)
148
159
  }
149
160
  }
150
161
  if (typeof e.name === 'string') {
151
162
  if (e.name === 'PrismaClientValidationError') return new HttpError(400, e.message || 'Invalid query parameters')
152
163
  if (e.name === 'PrismaClientKnownRequestError') return new HttpError(400, e.message || 'Database request error')
153
- if (e.name === 'PrismaClientInitializationError') return new HttpError(503, e.message || 'Database connection failed')
154
- if (e.name === 'PrismaClientRustPanicError') return new HttpError(500, e.message || 'Internal database engine error')
155
- if (e.name === 'PrismaClientUnknownRequestError') return new HttpError(500, e.message || 'Unknown database error')
164
+ if (e.name === 'PrismaClientInitializationError') {
165
+ return new HttpError(503, isProd ? 'Service unavailable' : (e.message || 'Database connection failed'))
166
+ }
167
+ if (e.name === 'PrismaClientRustPanicError') {
168
+ return new HttpError(500, isProd ? 'Internal server error' : (e.message || 'Internal database engine error'))
169
+ }
170
+ if (e.name === 'PrismaClientUnknownRequestError') {
171
+ return new HttpError(500, isProd ? 'Internal server error' : (e.message || 'Unknown database error'))
172
+ }
156
173
  }
157
174
  const msg = error instanceof Error ? error.message : String(error)
158
175
  console.error('[prisma-generator-express] Unhandled error:', error)
159
- return new HttpError(500, msg || 'Internal server error')
176
+ return new HttpError(500, isProd ? 'Internal server error' : (msg || 'Internal server error'))
160
177
  }
161
178
 
162
179
  type SpeedExtensionFactory = (opts: { postgres?: unknown; sqlite?: unknown; debug?: boolean }) => unknown
@@ -248,14 +265,16 @@ export function applyPaginationLimits(
248
265
  config?: PaginationConfig,
249
266
  hasGuardShape?: boolean,
250
267
  ): Record<string, unknown> {
251
- if (!config || hasGuardShape) return query
268
+ if (!config) return query
252
269
  const result: Record<string, unknown> = { ...query }
253
- if (result.take === undefined && config.defaultLimit !== undefined) {
270
+ if (!hasGuardShape && result.take === undefined && config.defaultLimit !== undefined) {
254
271
  result.take = config.defaultLimit
255
272
  }
256
273
  if (config.maxLimit !== undefined && result.take !== undefined) {
257
274
  const takeNum = Number(result.take)
258
- if (Math.abs(takeNum) > config.maxLimit) {
275
+ if (!Number.isFinite(takeNum)) {
276
+ result.take = config.maxLimit
277
+ } else if (Math.abs(takeNum) > config.maxLimit) {
259
278
  result.take = takeNum < 0 ? -config.maxLimit : config.maxLimit
260
279
  }
261
280
  }
@@ -47,15 +47,15 @@ export type ReadOperationConfig<
47
47
  }
48
48
 
49
49
  type ReadOperationOverrides<TShape, TCtx, TPrisma> = {
50
- findFirst?: ReadOperationConfig<TShape, TCtx, TPrisma>
51
- findFirstOrThrow?: ReadOperationConfig<TShape, TCtx, TPrisma>
52
- findUnique?: ReadOperationConfig<TShape, TCtx, TPrisma>
53
- findUniqueOrThrow?: ReadOperationConfig<TShape, TCtx, TPrisma>
54
- findMany?: ReadOperationConfig<TShape, TCtx, TPrisma>
55
- findManyPaginated?: ReadOperationConfig<TShape, TCtx, TPrisma>
56
- count?: ReadOperationConfig<TShape, TCtx, TPrisma>
57
- aggregate?: ReadOperationConfig<TShape, TCtx, TPrisma>
58
- groupBy?: ReadOperationConfig<TShape, TCtx, TPrisma>
50
+ findFirst?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
51
+ findFirstOrThrow?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
52
+ findUnique?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
53
+ findUniqueOrThrow?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
54
+ findMany?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
55
+ findManyPaginated?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
56
+ count?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
57
+ aggregate?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
58
+ groupBy?: ReadOperationConfig<TShape, TCtx, TPrisma> | false
59
59
  }
60
60
 
61
61
  export type RouteConfig<
@@ -1,4 +1,4 @@
1
- import type { Context, Next } from 'hono'
1
+ import type { Context } from 'hono'
2
2
  import type {
3
3
  BaseOperationConfig,
4
4
  BaseRouteConfig,
@@ -44,18 +44,76 @@ export type GeneratedHonoEnv<TEnv extends HonoEnvBase = HonoEnvBase> = {
44
44
  Bindings: TEnv['Bindings']
45
45
  }
46
46
 
47
- export type HonoHookHandler<TEnv extends HonoEnvBase = HonoEnvBase> = (
47
+ export type HonoBeforeHook<TEnv extends HonoEnvBase = HonoEnvBase> = (
48
48
  c: Context<GeneratedHonoEnv<TEnv>>,
49
- next: Next,
50
49
  ) => Promise<Response | void> | Response | void
51
50
 
51
+ export type HonoAfterHook<TEnv extends HonoEnvBase = HonoEnvBase> = (
52
+ c: Context<GeneratedHonoEnv<TEnv>>,
53
+ ) => Promise<Response | void> | Response | void
54
+
55
+ /** @deprecated use HonoBeforeHook or HonoAfterHook */
56
+ export type HonoHookHandler<TEnv extends HonoEnvBase = HonoEnvBase> = HonoBeforeHook<TEnv>
57
+
52
58
  export type OperationConfig<
53
59
  TShape = Record<string, unknown>,
54
60
  TEnv extends HonoEnvBase = HonoEnvBase,
55
- > = BaseOperationConfig<HonoHookHandler<TEnv>, TShape>
61
+ > = Omit<BaseOperationConfig<HonoBeforeHook<TEnv>, TShape>, 'before' | 'after'> & {
62
+ before?: HonoBeforeHook<TEnv>[]
63
+ after?: HonoAfterHook<TEnv>[]
64
+ }
65
+
66
+ export type UpdateEachConfig<TEnv extends HonoEnvBase = HonoEnvBase> = {
67
+ before?: HonoBeforeHook<TEnv>[]
68
+ after?: HonoAfterHook<TEnv>[]
69
+ }
70
+
71
+ type HonoOpKeys =
72
+ | 'findUnique'
73
+ | 'findUniqueOrThrow'
74
+ | 'findFirst'
75
+ | 'findFirstOrThrow'
76
+ | 'findMany'
77
+ | 'findManyPaginated'
78
+ | 'create'
79
+ | 'createMany'
80
+ | 'createManyAndReturn'
81
+ | 'update'
82
+ | 'updateMany'
83
+ | 'updateManyAndReturn'
84
+ | 'upsert'
85
+ | 'delete'
86
+ | 'deleteMany'
87
+ | 'aggregate'
88
+ | 'count'
89
+ | 'groupBy'
90
+ | 'updateEach'
56
91
 
57
92
  export type RouteConfig<
58
93
  TShape = Record<string, unknown>,
59
94
  TCtx = unknown,
60
95
  TEnv extends HonoEnvBase = HonoEnvBase,
61
- > = BaseRouteConfig<HonoHookHandler<TEnv>, Context<GeneratedHonoEnv<TEnv>>, TShape, TCtx>
96
+ > = Omit<
97
+ BaseRouteConfig<HonoBeforeHook<TEnv>, Context<GeneratedHonoEnv<TEnv>>, TShape, TCtx>,
98
+ HonoOpKeys
99
+ > & {
100
+ findUnique?: OperationConfig<TShape, TEnv> | false
101
+ findUniqueOrThrow?: OperationConfig<TShape, TEnv> | false
102
+ findFirst?: OperationConfig<TShape, TEnv> | false
103
+ findFirstOrThrow?: OperationConfig<TShape, TEnv> | false
104
+ findMany?: OperationConfig<TShape, TEnv> | false
105
+ findManyPaginated?: OperationConfig<TShape, TEnv> | false
106
+ create?: OperationConfig<TShape, TEnv> | false
107
+ createMany?: OperationConfig<TShape, TEnv> | false
108
+ createManyAndReturn?: OperationConfig<TShape, TEnv> | false
109
+ update?: OperationConfig<TShape, TEnv> | false
110
+ updateMany?: OperationConfig<TShape, TEnv> | false
111
+ updateManyAndReturn?: OperationConfig<TShape, TEnv> | false
112
+ upsert?: OperationConfig<TShape, TEnv> | false
113
+ delete?: OperationConfig<TShape, TEnv> | false
114
+ deleteMany?: OperationConfig<TShape, TEnv> | false
115
+ aggregate?: OperationConfig<TShape, TEnv> | false
116
+ count?: OperationConfig<TShape, TEnv> | false
117
+ groupBy?: OperationConfig<TShape, TEnv> | false
118
+ updateEach?: UpdateEachConfig<TEnv>
119
+ }
@@ -94,6 +94,11 @@ export interface BaseOperationConfig<HookHandler, TShape = Record<string, unknow
94
94
  pagination?: Partial<PaginationConfig>
95
95
  }
96
96
 
97
+ export interface BaseUpdateEachConfig<HookHandler> {
98
+ before?: HookHandler[]
99
+ after?: HookHandler[]
100
+ }
101
+
97
102
  export interface BaseRouteConfig<
98
103
  HookHandler,
99
104
  RequestType,
@@ -120,26 +125,45 @@ export interface BaseRouteConfig<
120
125
  resolveContext?: (request: RequestType) => TCtx | Promise<TCtx>
121
126
  queryBuilder?: QueryBuilderConfig | false
122
127
  pagination?: PaginationConfig
123
- findUnique?: BaseOperationConfig<HookHandler, TShape>
124
- findUniqueOrThrow?: BaseOperationConfig<HookHandler, TShape>
125
- findFirst?: BaseOperationConfig<HookHandler, TShape>
126
- findFirstOrThrow?: BaseOperationConfig<HookHandler, TShape>
127
- findMany?: BaseOperationConfig<HookHandler, TShape>
128
- findManyPaginated?: BaseOperationConfig<HookHandler, TShape>
129
- create?: BaseOperationConfig<HookHandler, TShape>
130
- createMany?: BaseOperationConfig<HookHandler, TShape>
131
- createManyAndReturn?: BaseOperationConfig<HookHandler, TShape>
132
- update?: BaseOperationConfig<HookHandler, TShape>
133
- updateMany?: BaseOperationConfig<HookHandler, TShape>
134
- updateManyAndReturn?: BaseOperationConfig<HookHandler, TShape>
135
- upsert?: BaseOperationConfig<HookHandler, TShape>
136
- delete?: BaseOperationConfig<HookHandler, TShape>
137
- deleteMany?: BaseOperationConfig<HookHandler, TShape>
138
- updateEach?: BaseOperationConfig<HookHandler, TShape>
139
- aggregate?: BaseOperationConfig<HookHandler, TShape>
140
- count?: BaseOperationConfig<HookHandler, TShape>
141
- groupBy?: BaseOperationConfig<HookHandler, TShape>
128
+ findUnique?: BaseOperationConfig<HookHandler, TShape> | false
129
+ findUniqueOrThrow?: BaseOperationConfig<HookHandler, TShape> | false
130
+ findFirst?: BaseOperationConfig<HookHandler, TShape> | false
131
+ findFirstOrThrow?: BaseOperationConfig<HookHandler, TShape> | false
132
+ findMany?: BaseOperationConfig<HookHandler, TShape> | false
133
+ findManyPaginated?: BaseOperationConfig<HookHandler, TShape> | false
134
+ create?: BaseOperationConfig<HookHandler, TShape> | false
135
+ createMany?: BaseOperationConfig<HookHandler, TShape> | false
136
+ createManyAndReturn?: BaseOperationConfig<HookHandler, TShape> | false
137
+ update?: BaseOperationConfig<HookHandler, TShape> | false
138
+ updateMany?: BaseOperationConfig<HookHandler, TShape> | false
139
+ updateManyAndReturn?: BaseOperationConfig<HookHandler, TShape> | false
140
+ upsert?: BaseOperationConfig<HookHandler, TShape> | false
141
+ delete?: BaseOperationConfig<HookHandler, TShape> | false
142
+ deleteMany?: BaseOperationConfig<HookHandler, TShape> | false
143
+ updateEach?: BaseUpdateEachConfig<HookHandler>
144
+ aggregate?: BaseOperationConfig<HookHandler, TShape> | false
145
+ count?: BaseOperationConfig<HookHandler, TShape> | false
146
+ groupBy?: BaseOperationConfig<HookHandler, TShape> | false
142
147
  }
143
148
 
144
149
  export type OperationConfig = BaseOperationConfig<unknown>
145
- export type RouteConfig = BaseRouteConfig<unknown, unknown>
150
+ export type RouteConfig = BaseRouteConfig<unknown, unknown>
151
+
152
+ export function validateCountSourceWhere(
153
+ cs: PaginationCountSource | undefined,
154
+ location: string,
155
+ ): void {
156
+ if (!cs) return
157
+ if ((cs as { type?: string }).type !== 'materializedView') return
158
+ const where = (cs as { where?: Record<string, unknown> }).where
159
+ if (!where) return
160
+ for (const [key, value] of Object.entries(where)) {
161
+ if (value === null) continue
162
+ if (typeof value === 'object') {
163
+ throw new Error(
164
+ location + ': countSource.where["' + key + '"] must be scalar or null; got ' +
165
+ (Array.isArray(value) ? 'array' : 'object'),
166
+ )
167
+ }
168
+ }
169
+ }
@@ -75,6 +75,17 @@ export async function ${exportName}(
75
75
  }`
76
76
  }).join('\n')
77
77
 
78
+ const updateEachExportName = `${modelName}UpdateEach`
79
+ const updateEachHandler = `
80
+ export async function ${updateEachExportName}(
81
+ request: FastifyRequest,
82
+ _reply: FastifyReply,
83
+ ): Promise<void> {
84
+ const atomic = request.headers['x-batch-atomic'] === 'true'
85
+ const data = await core.updateEach(buildContext(request), atomic)
86
+ ;(request as FastifyExtended).resultData = data
87
+ }`
88
+
78
89
  return `import type { FastifyRequest, FastifyReply } from 'fastify'
79
90
  import * as core from './${modelName}Core${ext}'
80
91
  import type { OperationContext, FindManyPaginatedMode } from '../operationRuntime${ext}'
@@ -108,5 +119,6 @@ function buildContext(request: FastifyRequest): OperationContext {
108
119
  }
109
120
  ${readHandlers}
110
121
  ${writeHandlers}
122
+ ${updateEachHandler}
111
123
  `
112
124
  }
@@ -50,7 +50,7 @@ export function generateHonoHandler(options: {
50
50
  const readHandlers = READ_OPS.map((op) => {
51
51
  const exportName = `${modelName}${op.charAt(0).toUpperCase() + op.slice(1)}`
52
52
  return `
53
- export async function ${exportName}(c: Context<HonoEnv>): Promise<void> {
53
+ export async function ${exportName}(c: HandlerContext): Promise<void> {
54
54
  const data = await core.${coreFnName(op)}(buildContext(c))
55
55
  c.set('resultData', data)
56
56
  }`
@@ -61,34 +61,29 @@ export async function ${exportName}(c: Context<HonoEnv>): Promise<void> {
61
61
  const statusCode = CREATED_OPS.has(op) ? 201 : 200
62
62
 
63
63
  return `
64
- export async function ${exportName}(c: Context<HonoEnv>): Promise<void> {
64
+ export async function ${exportName}(c: HandlerContext): Promise<void> {
65
65
  const data = await core.${coreFnName(op)}(buildContext(c))
66
66
  c.set('resultData', data)
67
67
  c.set('resultStatus', ${statusCode})
68
68
  }`
69
69
  }).join('\n')
70
70
 
71
+ const updateEachExportName = `${modelName}UpdateEach`
72
+ const updateEachHandler = `
73
+ export async function ${updateEachExportName}(c: HandlerContext): Promise<void> {
74
+ const atomic = c.req.header('x-batch-atomic') === 'true'
75
+ const data = await core.updateEach(buildContext(c), atomic)
76
+ c.set('resultData', data)
77
+ }`
78
+
71
79
  return `import type { Context } from 'hono'
72
80
  import * as core from './${modelName}Core${ext}'
73
- import type { OperationContext, FindManyPaginatedMode } from '../operationRuntime${ext}'
74
-
75
- type HonoVariables = {
76
- prisma: unknown
77
- postgres?: unknown
78
- sqlite?: unknown
79
- parsedQuery?: Record<string, unknown>
80
- body?: unknown
81
- routeConfig?: { pagination?: OperationContext['paginationConfig'] }
82
- guardShape?: Record<string, unknown>
83
- guardCaller?: string
84
- findManyPaginatedMode?: FindManyPaginatedMode
85
- resultData?: unknown
86
- resultStatus?: number
87
- }
81
+ import type { OperationContext } from '../operationRuntime${ext}'
82
+ import type { HonoInternalVariables } from '../routeConfig.target${ext}'
88
83
 
89
- type HonoEnv = { Variables: HonoVariables }
84
+ type HandlerContext = Context<{ Variables: HonoInternalVariables }>
90
85
 
91
- function buildContext(c: Context<HonoEnv>): OperationContext {
86
+ function buildContext(c: HandlerContext): OperationContext {
92
87
  return {
93
88
  prisma: c.get('prisma'),
94
89
  postgres: c.get('postgres'),
@@ -98,10 +93,10 @@ function buildContext(c: Context<HonoEnv>): OperationContext {
98
93
  guardShape: c.get('guardShape'),
99
94
  guardCaller: c.get('guardCaller'),
100
95
  paginationConfig: c.get('routeConfig')?.pagination,
101
- findManyPaginatedMode: c.get('findManyPaginatedMode'),
102
96
  }
103
97
  }
104
98
  ${readHandlers}
105
99
  ${writeHandlers}
100
+ ${updateEachHandler}
106
101
  `
107
102
  }
@@ -1,4 +1,5 @@
1
1
  import { GeneratorOptions } from '@prisma/generator-helper'
2
+ import * as fs from 'fs'
2
3
  import path from 'path'
3
4
 
4
5
  function findClientGenerator(options: GeneratorOptions) {
@@ -74,6 +75,18 @@ export function getGuardShapesImport(
74
75
  const outputValue = options.generator.output?.value
75
76
  if (!outputValue) return null
76
77
 
78
+ const shapesFilePath = path.join(guard.output.value, 'shapes.ts')
79
+ if (!fs.existsSync(shapesFilePath)) {
80
+ console.warn(
81
+ '[prisma-generator-express] prisma-guard generator detected but "' +
82
+ shapesFilePath +
83
+ '" was not found. Guard shapes will not be imported for model "' +
84
+ modelName +
85
+ '". Declare the "guard" generator before "express" in schema.prisma and re-run prisma generate.',
86
+ )
87
+ return null
88
+ }
89
+
77
90
  const fromDir = path.join(outputValue, modelName)
78
91
  const shapesPath = path.join(guard.output.value, 'shapes')
79
92
  return getRelativeImportPath(fromDir, shapesPath)
@@ -184,7 +184,7 @@ ${paginatedBody}
184
184
  const skip = (typeof query.skip === 'number' ? query.skip : 0)
185
185
  const takeRaw = (typeof query.take === 'number' ? query.take : items.length)
186
186
  const absTake = Math.abs(takeRaw)
187
- const hasMore = items.length >= absTake && skip + items.length < total
187
+ const hasMore = absTake > 0 && items.length >= absTake && skip + items.length < total
188
188
 
189
189
  return { data: items, total, hasMore }
190
190
  }
@@ -193,35 +193,76 @@ export async function updateEach(
193
193
  ctx: OperationContext,
194
194
  atomic: boolean,
195
195
  ): Promise<unknown> {
196
- const body = ctx.body
197
- if (!Array.isArray(body)) {
196
+ const rawBody = ctx.body
197
+ if (!Array.isArray(rawBody)) {
198
198
  throw new HttpError(400, 'updateEach body must be an array of { where, data } items')
199
199
  }
200
- const items = body as Record<string, unknown>[]
201
- const client = ctx.prisma as PrismaClientLike
200
+
201
+ const MAX_ITEMS_NON_ATOMIC = 1000
202
+ const MAX_ITEMS_ATOMIC = 100
203
+
204
+ if (atomic && rawBody.length > MAX_ITEMS_ATOMIC) {
205
+ throw new HttpError(
206
+ 400,
207
+ 'atomic updateEach body exceeds max size of ' + MAX_ITEMS_ATOMIC + ' items',
208
+ )
209
+ }
210
+ if (!atomic && rawBody.length > MAX_ITEMS_NON_ATOMIC) {
211
+ throw new HttpError(
212
+ 400,
213
+ 'updateEach body exceeds max size of ' + MAX_ITEMS_NON_ATOMIC + ' items',
214
+ )
215
+ }
216
+
217
+ const items = rawBody.map((item, index) => {
218
+ const sanitized = validateBody(item)
219
+ if (!('where' in sanitized) || sanitized.where === undefined) {
220
+ throw new HttpError(400, 'updateEach item at index ' + index + ' is missing "where"')
221
+ }
222
+ if (!('data' in sanitized) || sanitized.data === undefined) {
223
+ throw new HttpError(400, 'updateEach item at index ' + index + ' is missing "data"')
224
+ }
225
+ return sanitized
226
+ })
227
+ const extended = await getExtendedClient(ctx)
202
228
 
203
229
  if (atomic) {
204
- if (typeof client.$transaction !== 'function') {
230
+ const txClient = extended as PrismaClientLike
231
+ if (typeof txClient.$transaction !== 'function') {
205
232
  throw new HttpError(500, 'Atomic updateEach requires transaction support on the Prisma client')
206
233
  }
207
- const runInteractive = client.$transaction as unknown as <T>(
234
+ const runInteractive = txClient.$transaction as unknown as <T>(
208
235
  fn: (tx: unknown) => Promise<T>,
209
236
  ) => Promise<T>
210
237
  return runInteractive(async (tx) => {
211
238
  const txDelegate = getDelegate(tx, '${modelNameLower}')
212
- return Promise.all(items.map((item) => txDelegate.update(item)))
239
+ const out: unknown[] = new Array(items.length)
240
+ for (let i = 0; i < items.length; i++) {
241
+ out[i] = await txDelegate.update(items[i])
242
+ }
243
+ return out
213
244
  })
214
245
  }
215
246
 
216
- const delegate = getDelegate(client, '${modelNameLower}')
217
- const settled = await Promise.allSettled(
218
- items.map((item) => delegate.update(item)),
219
- )
220
- return settled.map((result) =>
221
- result.status === 'fulfilled'
222
- ? { status: 'ok', data: result.value }
223
- : { status: 'error', error: mapError(result.reason).message },
224
- )
247
+ const delegate = getDelegate(extended, '${modelNameLower}')
248
+ const CONCURRENCY = 8
249
+ const results: Array<{ status: 'ok'; data: unknown } | { status: 'error'; error: string }> =
250
+ new Array(items.length)
251
+ let cursor = 0
252
+ const workerCount = Math.min(CONCURRENCY, items.length)
253
+ const workers = Array.from({ length: workerCount }, async () => {
254
+ for (;;) {
255
+ const i = cursor++
256
+ if (i >= items.length) return
257
+ try {
258
+ results[i] = { status: 'ok', data: await delegate.update(items[i]) }
259
+ } catch (err) {
260
+ results[i] = { status: 'error', error: mapError(err).message }
261
+ }
262
+ }
263
+ })
264
+ await Promise.all(workers)
265
+ return results
225
266
  }
226
267
  `
227
268
  }