prisma-generator-express 1.43.0 → 1.44.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 (34) hide show
  1. package/dist/generators/generateFastifyHandler.d.ts +2 -0
  2. package/dist/generators/generateFastifyHandler.js +4 -2
  3. package/dist/generators/generateFastifyHandler.js.map +1 -1
  4. package/dist/generators/generateHonoHandler.d.ts +2 -0
  5. package/dist/generators/generateHonoHandler.js +4 -2
  6. package/dist/generators/generateHonoHandler.js.map +1 -1
  7. package/dist/generators/generateRelationMeta.js +7 -3
  8. package/dist/generators/generateRelationMeta.js.map +1 -1
  9. package/dist/generators/generateRouteConfigType.js +9 -1
  10. package/dist/generators/generateRouteConfigType.js.map +1 -1
  11. package/dist/generators/generateRouter.js +44 -18
  12. package/dist/generators/generateRouter.js.map +1 -1
  13. package/dist/generators/generateUnifiedHandler.d.ts +2 -0
  14. package/dist/generators/generateUnifiedHandler.js +4 -2
  15. package/dist/generators/generateUnifiedHandler.js.map +1 -1
  16. package/dist/generators/generateUnifiedScalarUI.d.ts +2 -0
  17. package/dist/generators/generateUnifiedScalarUI.js +19 -16
  18. package/dist/generators/generateUnifiedScalarUI.js.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/utils/writeFileSafely.js +0 -3
  21. package/dist/utils/writeFileSafely.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/copy/autoIncludePlanner.ts +64 -9
  24. package/src/copy/autoIncludeRuntime.ts +91 -36
  25. package/src/copy/operationRuntime.ts +12 -1
  26. package/src/generators/generateFastifyHandler.ts +6 -2
  27. package/src/generators/generateHonoHandler.ts +6 -2
  28. package/src/generators/generateRelationMeta.ts +8 -2
  29. package/src/generators/generateRouteConfigType.ts +8 -1
  30. package/src/generators/generateRouter.ts +44 -18
  31. package/src/generators/generateUnifiedHandler.ts +6 -2
  32. package/src/generators/generateUnifiedScalarUI.ts +21 -16
  33. package/src/index.ts +6 -6
  34. package/src/utils/writeFileSafely.ts +0 -3
@@ -45,12 +45,29 @@ export type AutoIncludePlannerInput = {
45
45
  export const DEFAULT_AUTO_INCLUDE_MAX_DEPTH = 3
46
46
  export const DEFAULT_AUTO_INCLUDE_MAX_STAGES = 20
47
47
 
48
+ const ALLOWED_TO_ONE_ARGS = new Set(['select', 'include', 'omit'])
49
+ const ALLOWED_TO_MANY_ARGS = new Set([
50
+ 'select',
51
+ 'include',
52
+ 'omit',
53
+ 'where',
54
+ 'orderBy',
55
+ 'take',
56
+ 'skip',
57
+ 'cursor',
58
+ 'distinct',
59
+ ])
60
+
48
61
  function isPlainObject(value: unknown): value is Record<string, unknown> {
49
62
  if (value === null || typeof value !== 'object') return false
50
63
  if (Array.isArray(value)) return false
51
64
  return true
52
65
  }
53
66
 
67
+ function isPubliclySelected(projection: Record<string, unknown>, field: string): boolean {
68
+ return projection[field] === true
69
+ }
70
+
54
71
  function hasCountKey(value: unknown): boolean {
55
72
  if (!isPlainObject(value)) return false
56
73
  return '_count' in value
@@ -117,11 +134,11 @@ function walk(
117
134
  parentArgs: Record<string, unknown>,
118
135
  depth: number,
119
136
  ): WalkResult {
120
- if (depth > ctx.maxDepth) {
121
- return { unsupportedReason: 'depth exceeds maxDepth=' + ctx.maxDepth }
137
+ if (depth >= ctx.maxDepth) {
138
+ return { unsupportedReason: 'nested depth reached maxDepth=' + ctx.maxDepth }
122
139
  }
123
- if (ctx.stages.length > ctx.maxStages) {
124
- return { unsupportedReason: 'stages exceed maxStages=' + ctx.maxStages }
140
+ if (ctx.stages.length >= ctx.maxStages) {
141
+ return { unsupportedReason: 'stages reached maxStages=' + ctx.maxStages }
125
142
  }
126
143
 
127
144
  const model = ctx.models[modelName]
@@ -154,12 +171,17 @@ function walk(
154
171
  const isSelectMode = isPlainObject(select)
155
172
  const localOmit = isPlainObject(omit) ? omit : null
156
173
  const updatedProjection: Record<string, unknown> = {}
157
- const userFields = new Set(Object.keys(projection))
158
174
 
159
175
  const relationBranches: Array<{ name: string; value: unknown }> = []
160
176
 
161
177
  for (const [key, value] of Object.entries(projection)) {
162
178
  if (model.relations[key]) {
179
+ if (value === false || value === null || value === undefined) {
180
+ if (isSelectMode) {
181
+ updatedProjection[key] = value
182
+ }
183
+ continue
184
+ }
163
185
  relationBranches.push({ name: key, value })
164
186
  } else {
165
187
  updatedProjection[key] = value
@@ -171,6 +193,10 @@ function walk(
171
193
  }
172
194
 
173
195
  for (const branch of relationBranches) {
196
+ if (ctx.stages.length >= ctx.maxStages) {
197
+ return { unsupportedReason: 'stages reached maxStages=' + ctx.maxStages }
198
+ }
199
+
174
200
  const relation = model.relations[branch.name]
175
201
 
176
202
  if (relation.direction === 'implicitM2M') {
@@ -182,6 +208,19 @@ function walk(
182
208
  if (relation.parentLinkFields.length !== relation.childLinkFields.length) {
183
209
  return { unsupportedReason: 'mismatched link field counts for ' + relation.name }
184
210
  }
211
+ if (!ctx.models[relation.type]) {
212
+ return {
213
+ unsupportedReason: 'target model ' + relation.type +
214
+ ' not in relation metadata for ' + (parentPath ? parentPath + '.' : '') + branch.name,
215
+ }
216
+ }
217
+
218
+ if (branch.value !== true && !isPlainObject(branch.value)) {
219
+ return {
220
+ unsupportedReason: 'invalid relation projection value for ' + branch.name +
221
+ ' (expected true or plain object)',
222
+ }
223
+ }
185
224
 
186
225
  for (const linkField of relation.parentLinkFields) {
187
226
  if (localOmit && localOmit[linkField] === true) {
@@ -191,16 +230,32 @@ function walk(
191
230
  }
192
231
 
193
232
  for (const linkField of relation.parentLinkFields) {
194
- if (isSelectMode && !userFields.has(linkField)) {
233
+ if (isSelectMode && !isPubliclySelected(projection, linkField)) {
195
234
  updatedProjection[linkField] = true
196
235
  const fullPath = parentPath ? parentPath + '.' + linkField : linkField
197
236
  ctx.internalFieldPaths.push(fullPath)
198
237
  }
199
238
  }
200
239
 
201
- const relationArgs: Record<string, unknown> = branch.value === true
202
- ? {}
203
- : (isPlainObject(branch.value) ? branch.value : {})
240
+ const relationArgs: Record<string, unknown> = branch.value === true ? {} : branch.value
241
+
242
+ const allowedArgs = relation.isList ? ALLOWED_TO_MANY_ARGS : ALLOWED_TO_ONE_ARGS
243
+ for (const key of Object.keys(relationArgs)) {
244
+ if (!allowedArgs.has(key)) {
245
+ return {
246
+ unsupportedReason: 'unsupported arg "' + key + '" for ' +
247
+ (relation.isList ? 'to-many' : 'to-one') + ' relation ' + relation.name,
248
+ }
249
+ }
250
+ }
251
+
252
+ const targetModel = ctx.models[relation.type]
253
+ if (hasWhereLikeRelationReference(relationArgs, targetModel)) {
254
+ return {
255
+ unsupportedReason: 'nested relation used in where/orderBy/cursor for ' +
256
+ relation.name + ' is not supported in MVP',
257
+ }
258
+ }
204
259
 
205
260
  const relationPath = parentPath ? parentPath + '.' + branch.name : branch.name
206
261
 
@@ -8,6 +8,7 @@ import {
8
8
  sendSSEError,
9
9
  sendSSEProgress,
10
10
  runSingleResultSSE,
11
+ emitTerminalSSEError,
11
12
  setByPath,
12
13
  getDelegate,
13
14
  getExtendedClient,
@@ -35,6 +36,7 @@ export type RunAutoIncludeOptions = {
35
36
  models: Record<string, ModelRelationMap>
36
37
  variantConfig: AutoIncludeProgressiveVariantConfig
37
38
  coreQueryFn: () => Promise<unknown>
39
+ signal?: AbortSignal
38
40
  }
39
41
 
40
42
  function isObject(v: unknown): v is Record<string, unknown> {
@@ -99,6 +101,29 @@ function emptyResultFor(isList: boolean): unknown {
99
101
  return isList ? [] : null
100
102
  }
101
103
 
104
+ function buildPublicForStage(
105
+ result: unknown,
106
+ internalFieldPaths: string[],
107
+ scopePath: string,
108
+ ): unknown {
109
+ if (Array.isArray(result)) {
110
+ return result.map((item) => {
111
+ if (isObject(item)) {
112
+ const copy: Record<string, unknown> = { ...item }
113
+ stripInternalAtScope(copy, internalFieldPaths, scopePath)
114
+ return copy
115
+ }
116
+ return item
117
+ })
118
+ }
119
+ if (isObject(result)) {
120
+ const copy: Record<string, unknown> = { ...result }
121
+ stripInternalAtScope(copy, internalFieldPaths, scopePath)
122
+ return copy
123
+ }
124
+ return result
125
+ }
126
+
102
127
  async function runOneStage(options: {
103
128
  extended: unknown
104
129
  models: Record<string, ModelRelationMap>
@@ -107,32 +132,36 @@ async function runOneStage(options: {
107
132
  publicState: Record<string, unknown>
108
133
  internalFieldPaths: string[]
109
134
  res: Response
135
+ isAborted: () => boolean
110
136
  }): Promise<void> {
111
- const { extended, models, stage, internal, publicState, internalFieldPaths, res } = options
112
- if (res.writableEnded || res.destroyed) return
137
+ const { extended, models, stage, internal, publicState, internalFieldPaths, res, isAborted } = options
138
+ if (isAborted()) return
113
139
 
114
140
  const parentRaw = readPath(internal, stage.parentPath)
115
141
  if (!isObject(parentRaw)) {
142
+ if (stage.parentPath !== '') {
143
+ return
144
+ }
116
145
  const empty = emptyResultFor(stage.relationField.isList)
117
- setByPath(publicState, stage.relationPath, empty)
118
- sendSSEField(res, stage.relationPath, empty)
146
+ const applied = setByPath(publicState, stage.relationPath, empty)
147
+ if (applied) sendSSEField(res, stage.relationPath, empty)
119
148
  return
120
149
  }
121
150
 
122
151
  const linkFilter = buildLinkFilter(stage, parentRaw)
123
152
  if (!linkFilter) {
124
153
  const empty = emptyResultFor(stage.relationField.isList)
125
- setByPath(publicState, stage.relationPath, empty)
126
- sendSSEField(res, stage.relationPath, empty)
154
+ const appliedInternal = setByPath(internal, stage.relationPath, empty)
155
+ const appliedPublic = setByPath(publicState, stage.relationPath, empty)
156
+ if (appliedInternal && appliedPublic) {
157
+ sendSSEField(res, stage.relationPath, empty)
158
+ }
127
159
  return
128
160
  }
129
161
 
130
162
  const targetModel = models[stage.relationField.type]
131
163
  if (!targetModel) {
132
- const empty = emptyResultFor(stage.relationField.isList)
133
- setByPath(publicState, stage.relationPath, empty)
134
- sendSSEField(res, stage.relationPath, empty)
135
- return
164
+ throw new Error('Target model not in relation metadata: ' + stage.relationField.type)
136
165
  }
137
166
 
138
167
  const finalArgs: Record<string, unknown> = { ...stage.stageArgs }
@@ -142,20 +171,20 @@ async function runOneStage(options: {
142
171
  const method: 'findMany' | 'findFirst' = stage.relationField.isList ? 'findMany' : 'findFirst'
143
172
  const result = await delegate[method](finalArgs)
144
173
 
145
- setByPath(internal, stage.relationPath, result)
174
+ if (isAborted()) return
146
175
 
147
- let publicResult: unknown
148
- if (Array.isArray(result)) {
149
- publicResult = result
150
- } else if (isObject(result)) {
151
- const copy: Record<string, unknown> = { ...result }
152
- stripInternalAtScope(copy, internalFieldPaths, stage.relationPath)
153
- publicResult = copy
154
- } else {
155
- publicResult = result
176
+ const appliedInternal = setByPath(internal, stage.relationPath, result)
177
+ if (!appliedInternal) {
178
+ throw new Error('Failed to apply internal patch for ' + stage.relationPath)
179
+ }
180
+
181
+ const publicResult = buildPublicForStage(result, internalFieldPaths, stage.relationPath)
182
+
183
+ const appliedPublic = setByPath(publicState, stage.relationPath, publicResult)
184
+ if (!appliedPublic) {
185
+ throw new Error('Failed to apply public patch for ' + stage.relationPath)
156
186
  }
157
187
 
158
- setByPath(publicState, stage.relationPath, publicResult)
159
188
  sendSSEField(res, stage.relationPath, publicResult)
160
189
  }
161
190
 
@@ -194,9 +223,16 @@ async function runConcurrent<T>(
194
223
  export async function runAutoIncludeProgressive(
195
224
  options: RunAutoIncludeOptions,
196
225
  ): Promise<void> {
197
- const { req, res, ctx, args, baseOp, modelName, delegateKey, models, variantConfig, coreQueryFn } = options
226
+ const { req, res, ctx, args, baseOp, modelName, delegateKey, models, variantConfig, coreQueryFn, signal } = options
227
+
228
+ const isClientGone = () =>
229
+ signal?.aborted === true || res.writableEnded || res.destroyed
198
230
 
199
231
  if (ctx.guardShape) {
232
+ if (variantConfig.fallback === 'error') {
233
+ emitTerminalSSEError(res, 'auto-progressive fallback: guard shape disables auto-include')
234
+ return
235
+ }
200
236
  return runSingleResultSSE({ req, res, coreQueryFn })
201
237
  }
202
238
 
@@ -208,14 +244,7 @@ export async function runAutoIncludeProgressive(
208
244
 
209
245
  if (plan.unsupportedReason) {
210
246
  if (variantConfig.fallback === 'error') {
211
- let keepalive: IntervalHandle | null = null
212
- try {
213
- initSSE(res)
214
- keepalive = startSSEKeepalive(res)
215
- sendSSEError(res, plan.unsupportedReason)
216
- } finally {
217
- endSSE(res, keepalive)
218
- }
247
+ emitTerminalSSEError(res, plan.unsupportedReason)
219
248
  return
220
249
  }
221
250
  return runSingleResultSSE({ req, res, coreQueryFn })
@@ -229,15 +258,18 @@ export async function runAutoIncludeProgressive(
229
258
  try {
230
259
  initSSE(res)
231
260
  keepalive = startSSEKeepalive(res)
232
- if (req.destroyed) return
261
+ if (isClientGone()) return
233
262
 
234
263
  const extended = await getExtendedClient(ctx)
264
+ if (isClientGone()) return
265
+
235
266
  const rootDelegate = getDelegate(extended, delegateKey)
236
267
 
237
268
  let rootResult: unknown
238
269
  try {
239
270
  rootResult = await rootDelegate[baseOp](plan.rootArgs)
240
271
  } catch (err) {
272
+ if (isClientGone()) return
241
273
  const code = (err as { code?: string } | null)?.code
242
274
  const isOrThrow = baseOp === 'findUniqueOrThrow' || baseOp === 'findFirstOrThrow'
243
275
  if (isOrThrow && code === 'P2025') {
@@ -249,7 +281,7 @@ export async function runAutoIncludeProgressive(
249
281
  return
250
282
  }
251
283
 
252
- if (res.writableEnded || res.destroyed) return
284
+ if (isClientGone()) return
253
285
 
254
286
  if (rootResult === null || !isObject(rootResult)) {
255
287
  sendSSEResult(res, null)
@@ -262,17 +294,28 @@ export async function runAutoIncludeProgressive(
262
294
 
263
295
  const publicState: Record<string, unknown> = { ...publicRoot }
264
296
  for (const [k, v] of Object.entries(publicRoot)) {
297
+ if (isClientGone()) return
265
298
  sendSSEField(res, k, v)
266
299
  }
267
300
 
301
+ if (isClientGone()) return
268
302
  sendSSEProgress(res, 'root', 0, plan.stages.length)
269
303
 
270
304
  const groups = groupStagesByDepth(plan.stages)
271
305
  let completed = 0
306
+ let stageErrorMessage: string | null = null
307
+ const isAborted = () =>
308
+ stageErrorMessage !== null ||
309
+ signal?.aborted === true ||
310
+ res.writableEnded ||
311
+ res.destroyed
272
312
 
273
313
  for (const group of groups) {
274
- if (res.writableEnded || res.destroyed) return
314
+ if (isClientGone()) return
315
+ if (stageErrorMessage) break
316
+
275
317
  await runConcurrent(group, STAGE_CONCURRENCY, async (stage) => {
318
+ if (isAborted()) return
276
319
  try {
277
320
  await runOneStage({
278
321
  extended,
@@ -282,21 +325,33 @@ export async function runAutoIncludeProgressive(
282
325
  publicState,
283
326
  internalFieldPaths: plan.internalFieldPaths,
284
327
  res,
328
+ isAborted,
285
329
  })
286
330
  } catch (err) {
331
+ if (isAborted()) return
287
332
  console.error('[auto-progressive] stage failed:', stage.relationPath, err)
288
- const empty = emptyResultFor(stage.relationField.isList)
289
- setByPath(publicState, stage.relationPath, empty)
290
- sendSSEField(res, stage.relationPath, empty)
333
+ stageErrorMessage = 'Could not load progressive response'
334
+ return
291
335
  }
336
+ if (isAborted()) return
292
337
  completed++
293
338
  sendSSEProgress(res, stage.relationPath, completed, plan.stages.length)
294
339
  })
295
340
  }
296
341
 
342
+ if (isClientGone()) return
343
+
344
+ if (stageErrorMessage) {
345
+ if (!res.writableEnded && !res.destroyed) {
346
+ sendSSEError(res, stageErrorMessage)
347
+ }
348
+ return
349
+ }
350
+
297
351
  if (res.writableEnded || res.destroyed) return
298
352
  sendSSEResult(res, publicState)
299
353
  } catch (err) {
354
+ if (isClientGone()) return
300
355
  console.error('[auto-progressive] dispatch error:', err)
301
356
  if (!res.writableEnded && !res.destroyed) {
302
357
  sendSSEError(res, 'Internal server error')
@@ -426,7 +426,7 @@ type SseWritable = {
426
426
  destroyed: boolean
427
427
  }
428
428
 
429
- function removeReqCloseListener(req: EventEmitterLike, listener: () => void): void {
429
+ export function removeReqCloseListener(req: EventEmitterLike, listener: () => void): void {
430
430
  if (typeof req.off === 'function') {
431
431
  req.off('close', listener)
432
432
  } else if (typeof req.removeListener === 'function') {
@@ -509,6 +509,17 @@ export function endSSE(res: SseWritable, keepaliveHandle: IntervalHandle | null)
509
509
  }
510
510
  }
511
511
 
512
+ export function emitTerminalSSEError(res: SseWritable, message: string): void {
513
+ let keepalive: IntervalHandle | null = null
514
+ try {
515
+ initSSE(res)
516
+ keepalive = startSSEKeepalive(res)
517
+ sendSSEError(res, message)
518
+ } finally {
519
+ endSSE(res, keepalive)
520
+ }
521
+ }
522
+
512
523
  export interface RunSingleResultSSEOptions {
513
524
  req: EventEmitterLike
514
525
  res: SseWritable
@@ -1,5 +1,7 @@
1
1
  import { DMMF } from '@prisma/generator-helper'
2
2
  import { toCamelCase } from '../utils/strings'
3
+ import { ImportStyle } from '../utils/resolveImportStyle'
4
+ import { importExt } from '../utils/importExt'
3
5
 
4
6
  const CORE_NAME_MAP: Record<string, string> = {
5
7
  delete: 'deleteUnique',
@@ -41,7 +43,9 @@ const CREATED_OPS = new Set([
41
43
 
42
44
  export function generateFastifyHandler(options: {
43
45
  model: DMMF.Model
46
+ importStyle: ImportStyle
44
47
  }): string {
48
+ const ext = importExt(options.importStyle)
45
49
  const modelName = options.model.name
46
50
  const prefix = toCamelCase(modelName)
47
51
 
@@ -75,8 +79,8 @@ export async function ${exportName}(
75
79
  }).join('\n')
76
80
 
77
81
  return `import type { FastifyRequest, FastifyReply } from 'fastify'
78
- import * as core from './${modelName}Core'
79
- import type { OperationContext } from '../operationRuntime'
82
+ import * as core from './${modelName}Core${ext}'
83
+ import type { OperationContext } from '../operationRuntime${ext}'
80
84
 
81
85
  type FastifyExtended = FastifyRequest & {
82
86
  prisma?: unknown
@@ -1,5 +1,7 @@
1
1
  import { DMMF } from '@prisma/generator-helper'
2
2
  import { toCamelCase } from '../utils/strings'
3
+ import { ImportStyle } from '../utils/resolveImportStyle'
4
+ import { importExt } from '../utils/importExt'
3
5
 
4
6
  const CORE_NAME_MAP: Record<string, string> = {
5
7
  delete: 'deleteUnique',
@@ -41,7 +43,9 @@ const CREATED_OPS = new Set([
41
43
 
42
44
  export function generateHonoHandler(options: {
43
45
  model: DMMF.Model
46
+ importStyle: ImportStyle
44
47
  }): string {
48
+ const ext = importExt(options.importStyle)
45
49
  const modelName = options.model.name
46
50
  const prefix = toCamelCase(modelName)
47
51
 
@@ -68,8 +72,8 @@ export async function ${exportName}(c: Context<HonoEnv>): Promise<void> {
68
72
  }).join('\n')
69
73
 
70
74
  return `import type { Context } from 'hono'
71
- import * as core from './${modelName}Core'
72
- import type { OperationContext } from '../operationRuntime'
75
+ import * as core from './${modelName}Core${ext}'
76
+ import type { OperationContext } from '../operationRuntime${ext}'
73
77
 
74
78
  type HonoVariables = {
75
79
  prisma: unknown
@@ -27,12 +27,18 @@ function findOppositeField(
27
27
  targetModelName: string,
28
28
  relationName: string | undefined,
29
29
  selfModelName: string,
30
+ selfFieldName: string,
30
31
  ): DMMF.Field | null {
31
32
  if (!relationName) return null
32
33
  const target = models.find((m) => m.name === targetModelName)
33
34
  if (!target) return null
35
+ const isSelfRelation = targetModelName === selfModelName
34
36
  return target.fields.find(
35
- (f) => f.kind === 'object' && f.relationName === relationName && f.type === selfModelName,
37
+ (f) =>
38
+ f.kind === 'object' &&
39
+ f.relationName === relationName &&
40
+ f.type === selfModelName &&
41
+ !(isSelfRelation && f.name === selfFieldName),
36
42
  ) || null
37
43
  }
38
44
 
@@ -56,7 +62,7 @@ function computeRelation(
56
62
  }
57
63
  }
58
64
 
59
- const opposite = findOppositeField(models, field.type, field.relationName, selfModelName)
65
+ const opposite = findOppositeField(models, field.type, field.relationName, selfModelName, field.name)
60
66
  if (opposite) {
61
67
  const oppFrom = (opposite.relationFromFields ?? []) as string[]
62
68
  const oppTo = (opposite.relationToFields ?? []) as string[]
@@ -42,6 +42,12 @@ function capitalize(s: string): string {
42
42
  return s.charAt(0).toUpperCase() + s.slice(1)
43
43
  }
44
44
 
45
+ function requestTypeFor(target: Target): string {
46
+ if (target === 'fastify') return `import('fastify').FastifyRequest`
47
+ if (target === 'hono') return `import('hono').Context`
48
+ return `import('express').Request`
49
+ }
50
+
45
51
  export function generateRouteConfigType(
46
52
  modelName: string,
47
53
  hookHandlerType: string,
@@ -52,6 +58,7 @@ export function generateRouteConfigType(
52
58
  const ext = importExt(importStyle)
53
59
  const m = modelName
54
60
  const supportsProgressive = target === 'express'
61
+ const requestType = requestTypeFor(target)
55
62
 
56
63
  const progressiveTypeImport = supportsProgressive
57
64
  ? `import type { ProgressiveVariantConfig, ProgressiveStage } from '../routeConfig.target${ext}'\n\n`
@@ -90,7 +97,7 @@ export function generateRouteConfigType(
90
97
  ` | ${omitKeys}\n` +
91
98
  ` | 'resolveContext'\n` +
92
99
  `> & {\n` +
93
- ` resolveContext?: (request: import('express').Request) => TCtx | Promise<TCtx>\n` +
100
+ ` resolveContext?: (request: ${requestType}) => TCtx | Promise<TCtx>\n` +
94
101
  `${overrides}\n}\n`
95
102
  )
96
103
  }
@@ -80,6 +80,10 @@ import {
80
80
  acceptsEventStream,
81
81
  runProgressiveEndpoint,
82
82
  runSingleResultSSE,
83
+ emitTerminalSSEError,
84
+ removeReqCloseListener,
85
+ mapError,
86
+ HttpError,
83
87
  } from '../operationRuntime${ext}'
84
88
  import { relationModels } from '../relationModels${ext}'
85
89
  import { runAutoIncludeProgressive } from '../autoIncludeRuntime${ext}'
@@ -215,7 +219,7 @@ export function ${routerFunctionName}<TCtx = unknown>(config: ${modelName}RouteC
215
219
  }
216
220
  }
217
221
 
218
- const maybeProgressiveSSE = (
222
+ const maybeProgressiveSSE = (
219
223
  opConfig: OperationConfigLike,
220
224
  coreFn: (ctx: OperationContext) => Promise<unknown>,
221
225
  baseOp: string,
@@ -246,7 +250,8 @@ export function ${routerFunctionName}<TCtx = unknown>(config: ${modelName}RouteC
246
250
 
247
251
  if (!isSingleRecordRead) {
248
252
  if (progressiveConfig.fallback === 'error') {
249
- return next({ status: 400, message: 'autoInclude mode supports only single-record reads' })
253
+ emitTerminalSSEError(res, 'auto-progressive fallback: operation not single-record')
254
+ return
250
255
  }
251
256
  await runSingleResultSSE({
252
257
  req,
@@ -256,25 +261,40 @@ export function ${routerFunctionName}<TCtx = unknown>(config: ${modelName}RouteC
256
261
  return
257
262
  }
258
263
 
259
- await runAutoIncludeProgressive({
264
+ const ctx = buildContext(req, res)
265
+ const args = (locals.parsedQuery ?? {}) as Record<string, unknown>
266
+ const controller = new AbortController()
267
+ const onClose = () => controller.abort()
268
+ req.on('close', onClose)
269
+ try {
270
+ await runAutoIncludeProgressive({
271
+ req,
272
+ res,
273
+ ctx,
274
+ args,
275
+ baseOp: baseOp as 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow',
276
+ modelName: '${modelName}',
277
+ delegateKey: '${delegateKey}',
278
+ models: relationModels,
279
+ variantConfig: progressiveConfig,
280
+ coreQueryFn: () => coreFn(ctx),
281
+ signal: controller.signal,
282
+ })
283
+ } finally {
284
+ removeReqCloseListener(req, onClose)
285
+ }
286
+ return
287
+ }
288
+
289
+ if (!Array.isArray(progressiveConfig.stages)) {
290
+ await runSingleResultSSE({
260
291
  req,
261
292
  res,
262
- ctx: buildContext(req, res),
263
- args: locals.parsedQuery ?? {},
264
- baseOp: baseOp as 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow',
265
- modelName: '${modelName}',
266
- delegateKey: '${delegateKey}',
267
- models: relationModels,
268
- variantConfig: progressiveConfig,
269
293
  coreQueryFn: () => coreFn(buildContext(req, res)),
270
294
  })
271
295
  return
272
296
  }
273
297
 
274
- if (!Array.isArray(progressiveConfig.stages)) {
275
- return next({ status: 500, message: 'Progressive endpoint requires stages array' })
276
- }
277
-
278
298
  const stageRegistry = opConfig.progressiveStages ?? {}
279
299
  const missingStage = progressiveConfig.stages.find(
280
300
  (name: string) => typeof stageRegistry[name] !== 'function',
@@ -454,10 +474,16 @@ export function ${routerFunctionName}<TCtx = unknown>(config: ${modelName}RouteC
454
474
  }
455
475
 
456
476
  router.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
457
- const e = err as { status?: number; message?: string }
458
- const status = typeof e.status === 'number' ? e.status : 500
459
- const message = e.message || 'Internal server error'
460
- if (!res.headersSent) return res.status(status).json({ message })
477
+ let httpError: HttpError
478
+ if (err instanceof HttpError) {
479
+ httpError = err
480
+ } else if (err && typeof err === 'object' && typeof (err as { status?: number }).status === 'number') {
481
+ const e = err as { status: number; message?: string }
482
+ httpError = new HttpError(e.status, e.message || 'Internal server error')
483
+ } else {
484
+ httpError = mapError(err)
485
+ }
486
+ if (!res.headersSent) return res.status(httpError.status).json({ message: httpError.message })
461
487
  next(err)
462
488
  })
463
489
 
@@ -1,8 +1,11 @@
1
1
  import { DMMF } from '@prisma/generator-helper'
2
2
  import { toCamelCase } from '../utils/strings'
3
+ import { ImportStyle } from '../utils/resolveImportStyle'
4
+ import { importExt } from '../utils/importExt'
3
5
 
4
6
  export interface UnifiedHandlerOptions {
5
7
  model: DMMF.Model
8
+ importStyle: ImportStyle
6
9
  }
7
10
 
8
11
  const CORE_NAME_MAP: Record<string, string> = {
@@ -35,6 +38,7 @@ const ALL_OPS = [
35
38
  ]
36
39
 
37
40
  export function generateUnifiedHandler(options: UnifiedHandlerOptions): string {
41
+ const ext = importExt(options.importStyle)
38
42
  const modelName = options.model.name
39
43
  const prefix = toCamelCase(modelName)
40
44
 
@@ -57,8 +61,8 @@ export async function ${exportName}(
57
61
  }).join('\n')
58
62
 
59
63
  return `import { Request, Response, NextFunction } from 'express'
60
- import * as core from './${modelName}Core'
61
- import { OperationContext, mapError } from '../operationRuntime'
64
+ import * as core from './${modelName}Core${ext}'
65
+ import { OperationContext, mapError } from '../operationRuntime${ext}'
62
66
 
63
67
  type ExtendedRequest = Request & {
64
68
  prisma?: unknown