prisma-generator-express 1.59.0 → 1.61.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 (67) hide show
  1. package/README.md +150 -14
  2. package/dist/client/encodeQueryParams.js +19 -23
  3. package/dist/client/encodeQueryParams.js.map +1 -1
  4. package/dist/copy/operationDefinitions.d.ts +37 -0
  5. package/dist/copy/operationDefinitions.js +412 -0
  6. package/dist/copy/operationDefinitions.js.map +1 -0
  7. package/dist/generators/generateFastifyHandler.js +10 -41
  8. package/dist/generators/generateFastifyHandler.js.map +1 -1
  9. package/dist/generators/generateHonoHandler.js +10 -41
  10. package/dist/generators/generateHonoHandler.js.map +1 -1
  11. package/dist/generators/generateModelMetadata.d.ts +8 -0
  12. package/dist/generators/generateModelMetadata.js +77 -0
  13. package/dist/generators/generateModelMetadata.js.map +1 -0
  14. package/dist/generators/generateOperationCore.js +20 -30
  15. package/dist/generators/generateOperationCore.js.map +1 -1
  16. package/dist/generators/generateQueryBuilderHelper.js +1 -1
  17. package/dist/generators/generateQueryBuilderHelper.js.map +1 -1
  18. package/dist/generators/generateRouteConfigType.js +11 -57
  19. package/dist/generators/generateRouteConfigType.js.map +1 -1
  20. package/dist/generators/generateRouter.js +64 -177
  21. package/dist/generators/generateRouter.js.map +1 -1
  22. package/dist/generators/generateRouterFastify.js +59 -169
  23. package/dist/generators/generateRouterFastify.js.map +1 -1
  24. package/dist/generators/generateRouterHono.js +52 -152
  25. package/dist/generators/generateRouterHono.js.map +1 -1
  26. package/dist/generators/generateUnifiedHandler.js +7 -30
  27. package/dist/generators/generateUnifiedHandler.js.map +1 -1
  28. package/dist/generators/generateUnifiedScalarUI.js +9 -74
  29. package/dist/generators/generateUnifiedScalarUI.js.map +1 -1
  30. package/dist/index.js +11 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/utils/copyFiles.js +7 -0
  33. package/dist/utils/copyFiles.js.map +1 -1
  34. package/dist/utils/writeFileSafely.js +5 -12
  35. package/dist/utils/writeFileSafely.js.map +1 -1
  36. package/package.json +12 -2
  37. package/src/client/encodeQueryParams.ts +23 -36
  38. package/src/copy/autoIncludePlanner.ts +14 -23
  39. package/src/copy/autoIncludePlannerGuarded.ts +477 -0
  40. package/src/copy/autoIncludeRuntime.ts +269 -261
  41. package/src/copy/autoIncludeRuntimeGuarded.ts +379 -0
  42. package/src/copy/buildModelOpenApi.ts +248 -628
  43. package/src/copy/concurrency.ts +20 -0
  44. package/src/copy/docsRenderer.ts +63 -333
  45. package/src/copy/errorMapper.ts +126 -0
  46. package/src/copy/guardHelpers.ts +56 -0
  47. package/src/copy/materializedCount.ts +68 -0
  48. package/src/copy/materializedRouter.ts +33 -29
  49. package/src/copy/operationDefinitions.ts +359 -35
  50. package/src/copy/operationRuntime.ts +11 -605
  51. package/src/copy/pagination.ts +151 -0
  52. package/src/copy/scalarTypes.ts +2 -0
  53. package/src/copy/sse.ts +296 -0
  54. package/src/generators/generateFastifyHandler.ts +13 -47
  55. package/src/generators/generateHonoHandler.ts +13 -47
  56. package/src/generators/generateModelMetadata.ts +92 -0
  57. package/src/generators/generateOperationCore.ts +19 -32
  58. package/src/generators/generateQueryBuilderHelper.ts +1 -1
  59. package/src/generators/generateRouteConfigType.ts +9 -60
  60. package/src/generators/generateRouter.ts +88 -180
  61. package/src/generators/generateRouterFastify.ts +65 -172
  62. package/src/generators/generateRouterHono.ts +58 -155
  63. package/src/generators/generateUnifiedHandler.ts +8 -33
  64. package/src/generators/generateUnifiedScalarUI.ts +9 -91
  65. package/src/index.ts +13 -1
  66. package/src/utils/copyFiles.ts +7 -0
  67. package/src/utils/writeFileSafely.ts +5 -11
@@ -0,0 +1,68 @@
1
+ import { HttpError } from './errorMapper'
2
+ import type { PaginationCountSource } from './routeConfig'
3
+
4
+ export const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
5
+
6
+ type PrismaRawClient = {
7
+ $queryRawUnsafe?: <T = unknown>(sql: string, ...values: unknown[]) => Promise<T>
8
+ }
9
+
10
+ export function quoteIdent(name: string): string {
11
+ if (!IDENT_RE.test(name)) {
12
+ throw new HttpError(400, 'invalid identifier: ' + name)
13
+ }
14
+ return '"' + name.replace(/"/g, '""') + '"'
15
+ }
16
+
17
+ function buildMaterializedCountFqn(
18
+ source: Extract<PaginationCountSource, { type: 'materializedView' }>,
19
+ ): string {
20
+ return source.schema
21
+ ? quoteIdent(source.schema) + '.' + quoteIdent(source.relation)
22
+ : quoteIdent(source.relation)
23
+ }
24
+
25
+ function buildMaterializedCountWhere(
26
+ where: Record<string, unknown> | undefined,
27
+ ): { sql: string; values: unknown[] } {
28
+ if (!where || Object.keys(where).length === 0) {
29
+ return { sql: '', values: [] }
30
+ }
31
+ const values: unknown[] = []
32
+ const clauses: string[] = []
33
+ for (const [key, value] of Object.entries(where)) {
34
+ if (value === null) {
35
+ clauses.push(quoteIdent(key) + ' IS NULL')
36
+ continue
37
+ }
38
+ values.push(value)
39
+ clauses.push(quoteIdent(key) + ' = $' + values.length)
40
+ }
41
+ return { sql: ' WHERE ' + clauses.join(' AND '), values }
42
+ }
43
+
44
+ export async function countFromMaterializedView(
45
+ client: unknown,
46
+ source: Extract<PaginationCountSource, { type: 'materializedView' }>,
47
+ ): Promise<number> {
48
+ const raw = client as PrismaRawClient
49
+ if (typeof raw.$queryRawUnsafe !== 'function') {
50
+ throw new HttpError(500, 'Materialized count source requires $queryRawUnsafe on the Prisma client')
51
+ }
52
+ const column = source.column ?? 'total'
53
+ const where = buildMaterializedCountWhere(source.where)
54
+ const sql =
55
+ 'SELECT ' +
56
+ quoteIdent(column) +
57
+ ' AS "total" FROM ' +
58
+ buildMaterializedCountFqn(source) +
59
+ where.sql +
60
+ ' LIMIT 1'
61
+ const rows = await raw.$queryRawUnsafe<Array<{ total: unknown }>>(sql, ...where.values)
62
+ const value = rows[0]?.total
63
+ const total = Number(value)
64
+ if (!Number.isFinite(total)) {
65
+ throw new HttpError(500, 'Materialized count source did not return a numeric total')
66
+ }
67
+ return Math.trunc(total)
68
+ }
@@ -6,7 +6,8 @@ import type {
6
6
  Response,
7
7
  Router,
8
8
  } from 'express'
9
- import { HttpError, mapError, transformResult } from './operationRuntime'
9
+ import { HttpError, mapError } from './errorMapper'
10
+ import { transformResult } from './operationRuntime'
10
11
 
11
12
  type SortDirection = 'asc' | 'desc'
12
13
  type NullsOrder = 'first' | 'last'
@@ -48,7 +49,8 @@ type MaterializedRouterOptions = {
48
49
 
49
50
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
50
51
 
51
- export const forbidden = (message: string): HttpError => new HttpError(403, message)
52
+ export const forbidden = (message: string): HttpError =>
53
+ new HttpError(403, message)
52
54
 
53
55
  const quoteIdent = (name: string): string => {
54
56
  if (!IDENT_RE.test(name))
@@ -105,6 +107,18 @@ const buildOrderBy = (orderBy?: OrderByDef): string => {
105
107
  )
106
108
  }
107
109
 
110
+ const validateSort = (value: unknown): SortDirection | undefined => {
111
+ if (value === undefined) return undefined
112
+ if (value === 'asc' || value === 'desc') return value
113
+ throw new HttpError(400, 'invalid orderBy.sort: expected "asc" or "desc"')
114
+ }
115
+
116
+ const validateNullsOrder = (value: unknown): NullsOrder | undefined => {
117
+ if (value === undefined) return undefined
118
+ if (value === 'first' || value === 'last') return value
119
+ throw new HttpError(400, 'invalid orderBy.nulls: expected "first" or "last"')
120
+ }
121
+
108
122
  const parseOrderByParam = (raw: unknown): OrderByDef | undefined => {
109
123
  if (raw === undefined || raw === null || raw === '') return undefined
110
124
  if (typeof raw !== 'string') return undefined
@@ -124,12 +138,10 @@ const parseOrderByParam = (raw: unknown): OrderByDef | undefined => {
124
138
  if (dirRaw === 'asc' || dirRaw === 'desc')
125
139
  return { field, direction: dirRaw }
126
140
  if (dirRaw && typeof dirRaw === 'object') {
127
- const sort = (dirRaw as { sort?: unknown }).sort
128
- const nulls = (dirRaw as { nulls?: unknown }).nulls
129
- const direction = sort === 'asc' || sort === 'desc' ? sort : undefined
130
- const nullsOrder =
131
- nulls === 'first' || nulls === 'last' ? nulls : undefined
132
- return { field, direction, nulls: nullsOrder }
141
+ const dirObj = dirRaw as { sort?: unknown; nulls?: unknown }
142
+ const direction = validateSort(dirObj.sort)
143
+ const nulls = validateNullsOrder(dirObj.nulls)
144
+ return { field, direction, nulls }
133
145
  }
134
146
  return { field }
135
147
  }
@@ -153,8 +165,12 @@ const validateViewIdentifier = (
153
165
  ): void => {
154
166
  if (!IDENT_RE.test(value)) {
155
167
  throw new Error(
156
- 'materializedViewsRouter: invalid ' + fieldName + ' identifier for view "' +
157
- viewName + '": ' + value,
168
+ 'materializedViewsRouter: invalid ' +
169
+ fieldName +
170
+ ' identifier for view "' +
171
+ viewName +
172
+ '": ' +
173
+ value,
158
174
  )
159
175
  }
160
176
  }
@@ -172,9 +188,10 @@ const validateViewConfig = (viewName: string, def: ViewDef): void => {
172
188
  }
173
189
  if (!def.allowedOrderBy && !def.orderBy) {
174
190
  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.',
191
+ '[materializedViewsRouter] view "' +
192
+ viewName +
193
+ '" has neither `orderBy` nor `allowedOrderBy` set. ' +
194
+ 'Clients can sort by any valid identifier, which may cause full table scans.',
178
195
  )
179
196
  }
180
197
  }
@@ -198,7 +215,7 @@ export const materializedViewsRouter = (
198
215
  ...before,
199
216
  async (req: Request, res: Response, next: NextFunction) => {
200
217
  try {
201
- const viewName = req.params.viewName
218
+ const viewName = String(req.params.viewName)
202
219
  const def = opts.views[viewName]
203
220
  if (!def) throw new HttpError(404, 'unknown view')
204
221
  if (def.authorize) await def.authorize(req, viewName, def)
@@ -247,26 +264,13 @@ export const materializedViewsRouter = (
247
264
 
248
265
  router.use(
249
266
  (err: unknown, _req: Request, res: Response, next: NextFunction) => {
250
- const httpError =
251
- err instanceof HttpError
252
- ? err
253
- : err &&
254
- typeof err === 'object' &&
255
- typeof (err as { status?: number }).status === 'number'
256
- ? new HttpError(
257
- (err as { status: number }).status,
258
- (err as { message?: string }).message ||
259
- 'Internal server error',
260
- )
261
- : mapError(err)
262
-
267
+ const httpError = mapError(err)
263
268
  if (!res.headersSent) {
264
269
  return res.status(httpError.status).json({ message: httpError.message })
265
270
  }
266
-
267
271
  next(err)
268
272
  },
269
273
  )
270
274
 
271
275
  return router
272
- }
276
+ }
@@ -1,122 +1,446 @@
1
1
  export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
2
2
 
3
- export interface OperationDef {
3
+ export type OpKind = 'read' | 'write' | 'batch'
4
+
5
+ export interface OpMetadata {
4
6
  name: string
5
7
  method: HttpMethod
6
8
  pathSuffix: string
7
9
  configKey: string
8
- excludeFromEnableAll?: boolean
10
+ kind: OpKind
11
+ coreName: string
12
+ successStatus: number
13
+ requiredBodyFields: readonly string[]
14
+ supportsPostRead: boolean
15
+ supportsProjection: boolean
16
+ excludeFromEnableAll: boolean
17
+ writeStrategyAffected: boolean
18
+ errors: readonly number[]
19
+ responseDesc: string
20
+ transport: string
21
+ argsSchema: readonly string[]
22
+ requiredArgs: readonly string[]
23
+ notes: string
9
24
  }
10
25
 
11
- export const OPERATION_DEFS: OperationDef[] = [
12
- { name: 'findMany', method: 'get', pathSuffix: '', configKey: 'findMany' },
26
+ export const OPERATION_METADATA: readonly OpMetadata[] = [
27
+ {
28
+ name: 'findMany',
29
+ method: 'get',
30
+ pathSuffix: '',
31
+ configKey: 'findMany',
32
+ kind: 'read',
33
+ coreName: 'findMany',
34
+ successStatus: 200,
35
+ requiredBodyFields: [],
36
+ supportsPostRead: true,
37
+ supportsProjection: true,
38
+ excludeFromEnableAll: false,
39
+ writeStrategyAffected: false,
40
+ errors: [400, 403, 500, 501, 503],
41
+ responseDesc: 'Array of records',
42
+ transport: 'GET query params',
43
+ argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
44
+ requiredArgs: [],
45
+ notes: 'Pagination limits may apply when configured.',
46
+ },
13
47
  {
14
48
  name: 'findUnique',
15
49
  method: 'get',
16
50
  pathSuffix: '/unique',
17
51
  configKey: 'findUnique',
52
+ kind: 'read',
53
+ coreName: 'findUnique',
54
+ successStatus: 200,
55
+ requiredBodyFields: [],
56
+ supportsPostRead: true,
57
+ supportsProjection: true,
58
+ excludeFromEnableAll: false,
59
+ writeStrategyAffected: false,
60
+ errors: [400, 403, 500, 501, 503],
61
+ responseDesc: 'Single record or null',
62
+ transport: 'GET query params',
63
+ argsSchema: ['where', 'select', 'include', 'omit'],
64
+ requiredArgs: ['where'],
65
+ notes: 'Returns null (not 404) when no record matches.',
18
66
  },
19
67
  {
20
68
  name: 'findUniqueOrThrow',
21
69
  method: 'get',
22
70
  pathSuffix: '/unique/strict',
23
71
  configKey: 'findUniqueOrThrow',
72
+ kind: 'read',
73
+ coreName: 'findUniqueOrThrow',
74
+ successStatus: 200,
75
+ requiredBodyFields: [],
76
+ supportsPostRead: true,
77
+ supportsProjection: true,
78
+ excludeFromEnableAll: false,
79
+ writeStrategyAffected: false,
80
+ errors: [400, 403, 404, 500, 501, 503],
81
+ responseDesc: 'Single record',
82
+ transport: 'GET query params',
83
+ argsSchema: ['where', 'select', 'include', 'omit'],
84
+ requiredArgs: ['where'],
85
+ notes: 'Returns 404 when no record matches.',
24
86
  },
25
87
  {
26
88
  name: 'findFirst',
27
89
  method: 'get',
28
90
  pathSuffix: '/first',
29
91
  configKey: 'findFirst',
92
+ kind: 'read',
93
+ coreName: 'findFirst',
94
+ successStatus: 200,
95
+ requiredBodyFields: [],
96
+ supportsPostRead: true,
97
+ supportsProjection: true,
98
+ excludeFromEnableAll: false,
99
+ writeStrategyAffected: false,
100
+ errors: [400, 403, 500, 501, 503],
101
+ responseDesc: 'Single record or null',
102
+ transport: 'GET query params',
103
+ argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
104
+ requiredArgs: [],
105
+ notes: 'Returns null (not 404) when no record matches.',
30
106
  },
31
107
  {
32
108
  name: 'findFirstOrThrow',
33
109
  method: 'get',
34
110
  pathSuffix: '/first/strict',
35
111
  configKey: 'findFirstOrThrow',
112
+ kind: 'read',
113
+ coreName: 'findFirstOrThrow',
114
+ successStatus: 200,
115
+ requiredBodyFields: [],
116
+ supportsPostRead: true,
117
+ supportsProjection: true,
118
+ excludeFromEnableAll: false,
119
+ writeStrategyAffected: false,
120
+ errors: [400, 403, 404, 500, 501, 503],
121
+ responseDesc: 'Single record',
122
+ transport: 'GET query params',
123
+ argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
124
+ requiredArgs: [],
125
+ notes: 'Returns 404 when no record matches.',
36
126
  },
37
127
  {
38
128
  name: 'findManyPaginated',
39
129
  method: 'get',
40
130
  pathSuffix: '/paginated',
41
131
  configKey: 'findManyPaginated',
132
+ kind: 'read',
133
+ coreName: 'findManyPaginated',
134
+ successStatus: 200,
135
+ requiredBodyFields: [],
136
+ supportsPostRead: true,
137
+ supportsProjection: true,
138
+ excludeFromEnableAll: false,
139
+ writeStrategyAffected: false,
140
+ errors: [400, 403, 409, 500, 501, 503],
141
+ responseDesc: '{ data: Record[], total: number, hasMore: boolean }',
142
+ transport: 'GET query params',
143
+ argsSchema: ['where', 'select', 'include', 'omit', 'orderBy', 'cursor', 'take', 'skip', 'distinct'],
144
+ requiredArgs: [],
145
+ 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.',
146
+ },
147
+ {
148
+ name: 'count',
149
+ method: 'get',
150
+ pathSuffix: '/count',
151
+ configKey: 'count',
152
+ kind: 'read',
153
+ coreName: 'count',
154
+ successStatus: 200,
155
+ requiredBodyFields: [],
156
+ supportsPostRead: true,
157
+ supportsProjection: false,
158
+ excludeFromEnableAll: false,
159
+ writeStrategyAffected: false,
160
+ errors: [400, 403, 500, 501, 503],
161
+ responseDesc: 'Integer, or per-field count object when select is provided',
162
+ transport: 'GET query params',
163
+ argsSchema: ['where', 'orderBy', 'cursor', 'take', 'skip', 'select'],
164
+ requiredArgs: [],
165
+ notes: 'select here means count-specific field selection, not record field selection.',
166
+ },
167
+ {
168
+ name: 'aggregate',
169
+ method: 'get',
170
+ pathSuffix: '/aggregate',
171
+ configKey: 'aggregate',
172
+ kind: 'read',
173
+ coreName: 'aggregate',
174
+ successStatus: 200,
175
+ requiredBodyFields: [],
176
+ supportsPostRead: true,
177
+ supportsProjection: false,
178
+ excludeFromEnableAll: false,
179
+ writeStrategyAffected: false,
180
+ errors: [400, 403, 500, 501, 503],
181
+ responseDesc: 'Object with requested aggregate fields (_count, _avg, _sum, _min, _max)',
182
+ transport: 'GET query params',
183
+ argsSchema: ['where', 'orderBy', 'cursor', 'take', 'skip', '_count', '_avg', '_sum', '_min', '_max'],
184
+ requiredArgs: [],
185
+ notes: '_avg, _sum only apply to numeric fields.',
186
+ },
187
+ {
188
+ name: 'groupBy',
189
+ method: 'get',
190
+ pathSuffix: '/groupby',
191
+ configKey: 'groupBy',
192
+ kind: 'read',
193
+ coreName: 'groupBy',
194
+ successStatus: 200,
195
+ requiredBodyFields: [],
196
+ supportsPostRead: true,
197
+ supportsProjection: false,
198
+ excludeFromEnableAll: false,
199
+ writeStrategyAffected: false,
200
+ errors: [400, 403, 500, 501, 503],
201
+ responseDesc: 'Array of objects, each with grouped field values and requested aggregates',
202
+ transport: 'GET query params',
203
+ argsSchema: ['by', 'where', 'orderBy', 'having', 'take', 'skip', '_count', '_avg', '_sum', '_min', '_max'],
204
+ requiredArgs: ['by'],
205
+ 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.',
206
+ },
207
+ {
208
+ name: 'create',
209
+ method: 'post',
210
+ pathSuffix: '',
211
+ configKey: 'create',
212
+ kind: 'write',
213
+ coreName: 'create',
214
+ successStatus: 201,
215
+ requiredBodyFields: ['data'],
216
+ supportsPostRead: false,
217
+ supportsProjection: true,
218
+ excludeFromEnableAll: false,
219
+ writeStrategyAffected: false,
220
+ errors: [400, 403, 409, 500, 501, 503],
221
+ responseDesc: 'Created record (201)',
222
+ transport: 'POST JSON body',
223
+ argsSchema: ['data', 'select', 'include', 'omit'],
224
+ requiredArgs: ['data'],
225
+ notes: '409 on unique constraint violation.',
42
226
  },
43
- { name: 'create', method: 'post', pathSuffix: '', configKey: 'create' },
44
227
  {
45
228
  name: 'createMany',
46
229
  method: 'post',
47
230
  pathSuffix: '/many',
48
231
  configKey: 'createMany',
232
+ kind: 'batch',
233
+ coreName: 'createMany',
234
+ successStatus: 201,
235
+ requiredBodyFields: ['data'],
236
+ supportsPostRead: false,
237
+ supportsProjection: false,
238
+ excludeFromEnableAll: false,
239
+ writeStrategyAffected: true,
240
+ errors: [400, 403, 409, 500, 501, 503],
241
+ responseDesc: '{ count: number } (201)',
242
+ transport: 'POST JSON body',
243
+ argsSchema: ['data', 'skipDuplicates'],
244
+ requiredArgs: ['data'],
245
+ notes: 'data is an array of scalar-only inputs. Nested relation writes are not supported. skipDuplicates silently ignores conflicts (not supported on all providers).',
49
246
  },
50
247
  {
51
248
  name: 'createManyAndReturn',
52
249
  method: 'post',
53
250
  pathSuffix: '/many/return',
54
251
  configKey: 'createManyAndReturn',
252
+ kind: 'batch',
253
+ coreName: 'createManyAndReturn',
254
+ successStatus: 201,
255
+ requiredBodyFields: ['data'],
256
+ supportsPostRead: false,
257
+ supportsProjection: true,
258
+ excludeFromEnableAll: false,
259
+ writeStrategyAffected: false,
260
+ errors: [400, 403, 409, 500, 501, 503],
261
+ responseDesc: 'Array of created records (201)',
262
+ transport: 'POST JSON body',
263
+ argsSchema: ['data', 'skipDuplicates', 'select', 'include', 'omit'],
264
+ requiredArgs: ['data'],
265
+ 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.',
266
+ },
267
+ {
268
+ name: 'update',
269
+ method: 'put',
270
+ pathSuffix: '',
271
+ configKey: 'update',
272
+ kind: 'write',
273
+ coreName: 'update',
274
+ successStatus: 200,
275
+ requiredBodyFields: ['where', 'data'],
276
+ supportsPostRead: false,
277
+ supportsProjection: true,
278
+ excludeFromEnableAll: false,
279
+ writeStrategyAffected: false,
280
+ errors: [400, 403, 404, 409, 500, 501, 503],
281
+ responseDesc: 'Updated record',
282
+ transport: 'PUT JSON body',
283
+ argsSchema: ['where', 'data', 'select', 'include', 'omit'],
284
+ requiredArgs: ['where', 'data'],
285
+ notes: '404 when the record to update is not found. 409 on unique constraint violation or transaction conflict.',
55
286
  },
56
- { name: 'update', method: 'put', pathSuffix: '', configKey: 'update' },
57
287
  {
58
288
  name: 'updateMany',
59
289
  method: 'put',
60
290
  pathSuffix: '/many',
61
291
  configKey: 'updateMany',
292
+ kind: 'batch',
293
+ coreName: 'updateMany',
294
+ successStatus: 200,
295
+ requiredBodyFields: ['where', 'data'],
296
+ supportsPostRead: false,
297
+ supportsProjection: false,
298
+ excludeFromEnableAll: false,
299
+ writeStrategyAffected: true,
300
+ errors: [400, 403, 409, 500, 501, 503],
301
+ responseDesc: '{ count: number }',
302
+ transport: 'PUT JSON body',
303
+ argsSchema: ['where', 'data'],
304
+ requiredArgs: ['where', 'data'],
305
+ notes: 'Updates all matching records with scalar-only data. Nested relation writes are not supported. Returns count, not records. 409 on unique constraint violation.',
62
306
  },
63
307
  {
64
308
  name: 'updateManyAndReturn',
65
309
  method: 'put',
66
310
  pathSuffix: '/many/return',
67
311
  configKey: 'updateManyAndReturn',
312
+ kind: 'batch',
313
+ coreName: 'updateManyAndReturn',
314
+ successStatus: 200,
315
+ requiredBodyFields: ['where', 'data'],
316
+ supportsPostRead: false,
317
+ supportsProjection: true,
318
+ excludeFromEnableAll: false,
319
+ writeStrategyAffected: false,
320
+ errors: [400, 403, 409, 500, 501, 503],
321
+ responseDesc: 'Array of updated records',
322
+ transport: 'PUT JSON body',
323
+ argsSchema: ['where', 'data', 'select', 'include', 'omit'],
324
+ requiredArgs: ['where', 'data'],
325
+ 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.',
68
326
  },
69
- { name: 'upsert', method: 'patch', pathSuffix: '', configKey: 'upsert' },
70
- { name: 'delete', method: 'delete', pathSuffix: '', configKey: 'delete' },
71
327
  {
72
- name: 'deleteMany',
73
- method: 'delete',
74
- pathSuffix: '/many',
75
- configKey: 'deleteMany',
328
+ name: 'upsert',
329
+ method: 'patch',
330
+ pathSuffix: '',
331
+ configKey: 'upsert',
332
+ kind: 'write',
333
+ coreName: 'upsert',
334
+ successStatus: 200,
335
+ requiredBodyFields: ['where', 'create', 'update'],
336
+ supportsPostRead: false,
337
+ supportsProjection: true,
338
+ excludeFromEnableAll: false,
339
+ writeStrategyAffected: false,
340
+ errors: [400, 403, 409, 500, 501, 503],
341
+ responseDesc: 'Created or updated record',
342
+ transport: 'PATCH JSON body',
343
+ argsSchema: ['where', 'create', 'update', 'select', 'include', 'omit'],
344
+ requiredArgs: ['where', 'create', 'update'],
345
+ notes: 'Creates if not found, updates if found.',
76
346
  },
77
- { name: 'count', method: 'get', pathSuffix: '/count', configKey: 'count' },
78
347
  {
79
- name: 'aggregate',
80
- method: 'get',
81
- pathSuffix: '/aggregate',
82
- configKey: 'aggregate',
348
+ name: 'delete',
349
+ method: 'delete',
350
+ pathSuffix: '',
351
+ configKey: 'delete',
352
+ kind: 'write',
353
+ coreName: 'deleteUnique',
354
+ successStatus: 200,
355
+ requiredBodyFields: ['where'],
356
+ supportsPostRead: false,
357
+ supportsProjection: true,
358
+ excludeFromEnableAll: false,
359
+ writeStrategyAffected: false,
360
+ errors: [400, 403, 404, 500, 501, 503],
361
+ responseDesc: 'Deleted record',
362
+ transport: 'DELETE JSON body',
363
+ argsSchema: ['where', 'select', 'include', 'omit'],
364
+ requiredArgs: ['where'],
365
+ notes: '404 when the record to delete is not found.',
83
366
  },
84
367
  {
85
- name: 'groupBy',
86
- method: 'get',
87
- pathSuffix: '/groupby',
88
- configKey: 'groupBy',
368
+ name: 'deleteMany',
369
+ method: 'delete',
370
+ pathSuffix: '/many',
371
+ configKey: 'deleteMany',
372
+ kind: 'batch',
373
+ coreName: 'deleteMany',
374
+ successStatus: 200,
375
+ requiredBodyFields: ['where'],
376
+ supportsPostRead: false,
377
+ supportsProjection: false,
378
+ excludeFromEnableAll: false,
379
+ writeStrategyAffected: false,
380
+ errors: [400, 403, 500, 501, 503],
381
+ responseDesc: '{ count: number }',
382
+ transport: 'DELETE JSON body',
383
+ argsSchema: ['where'],
384
+ requiredArgs: ['where'],
385
+ notes: 'Deletes all matching records. Returns count, not records.',
89
386
  },
90
387
  {
91
388
  name: 'updateEach',
92
389
  method: 'post',
93
390
  pathSuffix: '/each',
94
391
  configKey: 'updateEach',
392
+ kind: 'batch',
393
+ coreName: 'updateEach',
394
+ successStatus: 200,
395
+ requiredBodyFields: [],
396
+ supportsPostRead: false,
397
+ supportsProjection: false,
95
398
  excludeFromEnableAll: true,
399
+ writeStrategyAffected: false,
400
+ errors: [400, 403, 409, 500, 501, 503],
401
+ responseDesc: 'Non-atomic: per-row { status, data } / { status, error } array. Atomic: array of records.',
402
+ transport: 'POST JSON body (array)',
403
+ argsSchema: [],
404
+ requiredArgs: ['array of { where, data }'],
405
+ notes: 'Internal batch endpoint. Bypasses guard shapes. Not enabled by enableAll. Non-atomic max 1000 items, atomic max 100 items. Header x-batch-atomic:true switches to transactional mode.',
96
406
  },
97
407
  ]
98
408
 
99
- export const READ_OPERATION_NAMES = new Set([
100
- 'findMany',
101
- 'findUnique',
102
- 'findUniqueOrThrow',
103
- 'findFirst',
104
- 'findFirstOrThrow',
105
- 'findManyPaginated',
106
- 'count',
107
- 'aggregate',
108
- 'groupBy',
109
- ])
409
+ export const OPERATION_BY_NAME: Record<string, OpMetadata> = Object.fromEntries(
410
+ OPERATION_METADATA.map((m) => [m.name, m]),
411
+ )
412
+
413
+ export const READ_OPERATIONS: readonly OpMetadata[] = OPERATION_METADATA.filter((m) => m.kind === 'read')
414
+
415
+ export const WRITE_OPERATIONS: readonly OpMetadata[] = OPERATION_METADATA.filter((m) => m.kind === 'write' || m.kind === 'batch')
416
+
417
+ export const READ_OPERATION_NAMES = new Set(READ_OPERATIONS.map((m) => m.name))
418
+
419
+ export interface OperationDef {
420
+ name: string
421
+ method: HttpMethod
422
+ pathSuffix: string
423
+ configKey: string
424
+ excludeFromEnableAll?: boolean
425
+ }
426
+
427
+ export const OPERATION_DEFS: OperationDef[] = OPERATION_METADATA.map((m) => ({
428
+ name: m.name,
429
+ method: m.method,
430
+ pathSuffix: m.pathSuffix,
431
+ configKey: m.configKey,
432
+ excludeFromEnableAll: m.excludeFromEnableAll || undefined,
433
+ }))
110
434
 
111
435
  export function getPostReadPathSuffix(opName: string): string {
112
436
  if (opName === 'findMany') return '/read'
113
- const def = OPERATION_DEFS.find((d) => d.name === opName)
114
- return def ? def.pathSuffix : ''
437
+ const meta = OPERATION_BY_NAME[opName]
438
+ return meta ? meta.pathSuffix : ''
115
439
  }
116
440
 
117
441
  export function isOperationEnabled(
118
442
  config: Record<string, any>,
119
- def: OperationDef,
443
+ def: OperationDef | OpMetadata,
120
444
  ): boolean {
121
445
  if (config[def.configKey] === false) return false
122
446
  if (def.excludeFromEnableAll) return !!config[def.configKey]