prisma-generator-express 1.41.0 → 1.43.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 (45) hide show
  1. package/dist/generators/generateFastifyHandler.js +17 -4
  2. package/dist/generators/generateFastifyHandler.js.map +1 -1
  3. package/dist/generators/generateHonoHandler.js +4 -4
  4. package/dist/generators/generateOperationCore.d.ts +0 -1
  5. package/dist/generators/generateOperationCore.js +34 -546
  6. package/dist/generators/generateOperationCore.js.map +1 -1
  7. package/dist/generators/generateRelationMeta.d.ts +13 -0
  8. package/dist/generators/generateRelationMeta.js +106 -0
  9. package/dist/generators/generateRelationMeta.js.map +1 -0
  10. package/dist/generators/generateRouteConfigType.js +6 -6
  11. package/dist/generators/generateRouteConfigType.js.map +1 -1
  12. package/dist/generators/generateRouter.js +141 -60
  13. package/dist/generators/generateRouter.js.map +1 -1
  14. package/dist/generators/generateRouterFastify.js +127 -384
  15. package/dist/generators/generateRouterFastify.js.map +1 -1
  16. package/dist/generators/generateRouterHono.js +48 -36
  17. package/dist/generators/generateRouterHono.js.map +1 -1
  18. package/dist/generators/generateUnifiedHandler.js +24 -8
  19. package/dist/generators/generateUnifiedHandler.js.map +1 -1
  20. package/dist/index.js +21 -5
  21. package/dist/index.js.map +1 -1
  22. package/dist/utils/copyFiles.js +12 -0
  23. package/dist/utils/copyFiles.js.map +1 -1
  24. package/dist/utils/writeFileSafely.js +3 -0
  25. package/dist/utils/writeFileSafely.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/copy/autoIncludePlanner.ts +299 -0
  28. package/src/copy/autoIncludeRuntime.ts +307 -0
  29. package/src/copy/operationRuntime.ts +603 -0
  30. package/src/copy/routeConfig.express.ts +5 -3
  31. package/src/copy/routeConfig.fastify.ts +2 -2
  32. package/src/copy/routeConfig.hono.ts +3 -3
  33. package/src/copy/routeConfig.ts +20 -9
  34. package/src/generators/generateFastifyHandler.ts +17 -4
  35. package/src/generators/generateHonoHandler.ts +4 -4
  36. package/src/generators/generateOperationCore.ts +34 -546
  37. package/src/generators/generateRelationMeta.ts +154 -0
  38. package/src/generators/generateRouteConfigType.ts +7 -7
  39. package/src/generators/generateRouter.ts +141 -60
  40. package/src/generators/generateRouterFastify.ts +127 -384
  41. package/src/generators/generateRouterHono.ts +48 -36
  42. package/src/generators/generateUnifiedHandler.ts +24 -8
  43. package/src/index.ts +25 -7
  44. package/src/utils/copyFiles.ts +13 -0
  45. package/src/utils/writeFileSafely.ts +3 -0
@@ -0,0 +1,299 @@
1
+ export type ModelRelationDirection = 'parentOwnsFk' | 'childOwnsFk' | 'implicitM2M'
2
+
3
+ export type ModelRelationField = {
4
+ name: string
5
+ type: string
6
+ isList: boolean
7
+ isRequired: boolean
8
+ direction: ModelRelationDirection
9
+ parentLinkFields: string[]
10
+ childLinkFields: string[]
11
+ }
12
+
13
+ export type ModelRelationMap = {
14
+ name: string
15
+ delegateKey: string
16
+ scalarFields: string[]
17
+ idFields: string[]
18
+ relations: Record<string, ModelRelationField>
19
+ }
20
+
21
+ export type AutoIncludeStage = {
22
+ relationPath: string
23
+ parentPath: string
24
+ relationName: string
25
+ relationField: ModelRelationField
26
+ stageArgs: Record<string, unknown>
27
+ depth: number
28
+ }
29
+
30
+ export type AutoIncludePlan = {
31
+ rootArgs: Record<string, unknown>
32
+ stages: AutoIncludeStage[]
33
+ internalFieldPaths: string[]
34
+ unsupportedReason?: string
35
+ }
36
+
37
+ export type AutoIncludePlannerInput = {
38
+ rootModelName: string
39
+ models: Record<string, ModelRelationMap>
40
+ args: Record<string, unknown>
41
+ maxDepth?: number
42
+ maxStages?: number
43
+ }
44
+
45
+ export const DEFAULT_AUTO_INCLUDE_MAX_DEPTH = 3
46
+ export const DEFAULT_AUTO_INCLUDE_MAX_STAGES = 20
47
+
48
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
49
+ if (value === null || typeof value !== 'object') return false
50
+ if (Array.isArray(value)) return false
51
+ return true
52
+ }
53
+
54
+ function hasCountKey(value: unknown): boolean {
55
+ if (!isPlainObject(value)) return false
56
+ return '_count' in value
57
+ }
58
+
59
+ function hasWhereLikeRelationReference(
60
+ args: Record<string, unknown>,
61
+ model: ModelRelationMap,
62
+ ): boolean {
63
+ const checkObject = (obj: unknown): boolean => {
64
+ if (!isPlainObject(obj)) return false
65
+ for (const key of Object.keys(obj)) {
66
+ if (model.relations[key]) return true
67
+ if (key === 'AND' || key === 'OR' || key === 'NOT') {
68
+ const sub = obj[key]
69
+ if (Array.isArray(sub)) {
70
+ for (const item of sub) if (checkObject(item)) return true
71
+ } else if (checkObject(sub)) {
72
+ return true
73
+ }
74
+ }
75
+ }
76
+ return false
77
+ }
78
+
79
+ if (checkObject(args.where)) return true
80
+
81
+ if (args.orderBy) {
82
+ const obs = Array.isArray(args.orderBy) ? args.orderBy : [args.orderBy]
83
+ for (const ob of obs) {
84
+ if (!isPlainObject(ob)) continue
85
+ for (const key of Object.keys(ob)) {
86
+ if (model.relations[key]) return true
87
+ }
88
+ }
89
+ }
90
+
91
+ if (isPlainObject(args.cursor)) {
92
+ for (const key of Object.keys(args.cursor)) {
93
+ if (model.relations[key]) return true
94
+ }
95
+ }
96
+
97
+ return false
98
+ }
99
+
100
+ type WalkContext = {
101
+ models: Record<string, ModelRelationMap>
102
+ stages: AutoIncludeStage[]
103
+ internalFieldPaths: string[]
104
+ maxDepth: number
105
+ maxStages: number
106
+ }
107
+
108
+ type WalkResult = {
109
+ unsupportedReason?: string
110
+ projectionAfterStrip?: Record<string, unknown>
111
+ }
112
+
113
+ function walk(
114
+ ctx: WalkContext,
115
+ modelName: string,
116
+ parentPath: string,
117
+ parentArgs: Record<string, unknown>,
118
+ depth: number,
119
+ ): WalkResult {
120
+ if (depth > ctx.maxDepth) {
121
+ return { unsupportedReason: 'depth exceeds maxDepth=' + ctx.maxDepth }
122
+ }
123
+ if (ctx.stages.length > ctx.maxStages) {
124
+ return { unsupportedReason: 'stages exceed maxStages=' + ctx.maxStages }
125
+ }
126
+
127
+ const model = ctx.models[modelName]
128
+ if (!model) {
129
+ return { unsupportedReason: 'model ' + modelName + ' not in relation metadata' }
130
+ }
131
+
132
+ const select = parentArgs.select
133
+ const include = parentArgs.include
134
+ const omit = parentArgs.omit
135
+
136
+ if (isPlainObject(select) && isPlainObject(include)) {
137
+ return { unsupportedReason: 'select+include at same level' }
138
+ }
139
+ if (isPlainObject(select) && isPlainObject(omit)) {
140
+ return { unsupportedReason: 'select+omit at same level' }
141
+ }
142
+ if (hasCountKey(select) || hasCountKey(include)) {
143
+ return { unsupportedReason: '_count not supported in MVP' }
144
+ }
145
+ if (hasWhereLikeRelationReference(parentArgs, model)) {
146
+ return { unsupportedReason: 'relation used in where/orderBy/cursor is not supported in MVP' }
147
+ }
148
+
149
+ const projection = isPlainObject(select) ? select : (isPlainObject(include) ? include : null)
150
+ if (!projection) {
151
+ return {}
152
+ }
153
+
154
+ const isSelectMode = isPlainObject(select)
155
+ const localOmit = isPlainObject(omit) ? omit : null
156
+ const updatedProjection: Record<string, unknown> = {}
157
+ const userFields = new Set(Object.keys(projection))
158
+
159
+ const relationBranches: Array<{ name: string; value: unknown }> = []
160
+
161
+ for (const [key, value] of Object.entries(projection)) {
162
+ if (model.relations[key]) {
163
+ relationBranches.push({ name: key, value })
164
+ } else {
165
+ updatedProjection[key] = value
166
+ }
167
+ }
168
+
169
+ if (relationBranches.length === 0) {
170
+ return { projectionAfterStrip: isSelectMode ? updatedProjection : undefined }
171
+ }
172
+
173
+ for (const branch of relationBranches) {
174
+ const relation = model.relations[branch.name]
175
+
176
+ if (relation.direction === 'implicitM2M') {
177
+ return { unsupportedReason: 'implicit many-to-many not supported in MVP' }
178
+ }
179
+ if (relation.parentLinkFields.length === 0 || relation.childLinkFields.length === 0) {
180
+ return { unsupportedReason: 'ambiguous relation metadata for ' + relation.name }
181
+ }
182
+ if (relation.parentLinkFields.length !== relation.childLinkFields.length) {
183
+ return { unsupportedReason: 'mismatched link field counts for ' + relation.name }
184
+ }
185
+
186
+ for (const linkField of relation.parentLinkFields) {
187
+ if (localOmit && localOmit[linkField] === true) {
188
+ const where = parentPath ? parentPath + '.' : 'root '
189
+ return { unsupportedReason: 'required parent link field omitted: ' + where + linkField }
190
+ }
191
+ }
192
+
193
+ for (const linkField of relation.parentLinkFields) {
194
+ if (isSelectMode && !userFields.has(linkField)) {
195
+ updatedProjection[linkField] = true
196
+ const fullPath = parentPath ? parentPath + '.' + linkField : linkField
197
+ ctx.internalFieldPaths.push(fullPath)
198
+ }
199
+ }
200
+
201
+ const relationArgs: Record<string, unknown> = branch.value === true
202
+ ? {}
203
+ : (isPlainObject(branch.value) ? branch.value : {})
204
+
205
+ const relationPath = parentPath ? parentPath + '.' + branch.name : branch.name
206
+
207
+ const stageArgs: Record<string, unknown> = {}
208
+ for (const [k, v] of Object.entries(relationArgs)) {
209
+ if (k === 'select' || k === 'include' || k === 'omit') continue
210
+ stageArgs[k] = v
211
+ }
212
+
213
+ const stageIndex = ctx.stages.length
214
+
215
+ ctx.stages.push({
216
+ relationPath,
217
+ parentPath,
218
+ relationName: branch.name,
219
+ relationField: relation,
220
+ stageArgs,
221
+ depth: depth + 1,
222
+ })
223
+
224
+ const hasNestedProjection =
225
+ isPlainObject(relationArgs.select) ||
226
+ isPlainObject(relationArgs.include) ||
227
+ isPlainObject(relationArgs.omit)
228
+
229
+ if (hasNestedProjection) {
230
+ const stagesBeforeRecursion = ctx.stages.length
231
+
232
+ const nested = walk(ctx, relation.type, relationPath, relationArgs, depth + 1)
233
+ if (nested.unsupportedReason) return nested
234
+
235
+ if (relation.isList && ctx.stages.length > stagesBeforeRecursion) {
236
+ return { unsupportedReason: 'nested relation through to-many parent not supported in MVP' }
237
+ }
238
+
239
+ if (nested.projectionAfterStrip) {
240
+ ctx.stages[stageIndex].stageArgs.select = nested.projectionAfterStrip
241
+ }
242
+ if (isPlainObject(relationArgs.omit)) {
243
+ ctx.stages[stageIndex].stageArgs.omit = relationArgs.omit
244
+ }
245
+ }
246
+ }
247
+
248
+ return { projectionAfterStrip: isSelectMode ? updatedProjection : undefined }
249
+ }
250
+
251
+ export function planAutoInclude(input: AutoIncludePlannerInput): AutoIncludePlan {
252
+ const maxDepth = input.maxDepth ?? DEFAULT_AUTO_INCLUDE_MAX_DEPTH
253
+ const maxStages = input.maxStages ?? DEFAULT_AUTO_INCLUDE_MAX_STAGES
254
+
255
+ const ctx: WalkContext = {
256
+ models: input.models,
257
+ stages: [],
258
+ internalFieldPaths: [],
259
+ maxDepth,
260
+ maxStages,
261
+ }
262
+
263
+ const result = walk(ctx, input.rootModelName, '', input.args, 0)
264
+
265
+ if (result.unsupportedReason) {
266
+ return {
267
+ rootArgs: input.args,
268
+ stages: [],
269
+ internalFieldPaths: [],
270
+ unsupportedReason: 'auto-progressive fallback: ' + result.unsupportedReason,
271
+ }
272
+ }
273
+
274
+ const rootArgs: Record<string, unknown> = { ...input.args }
275
+ const rootModel = input.models[input.rootModelName]
276
+
277
+ if (result.projectionAfterStrip) {
278
+ rootArgs.select = result.projectionAfterStrip
279
+ delete rootArgs.include
280
+ } else if (rootModel && isPlainObject(input.args.include)) {
281
+ const stripped: Record<string, unknown> = {}
282
+ for (const [k, v] of Object.entries(input.args.include)) {
283
+ if (!rootModel.relations[k]) {
284
+ stripped[k] = v
285
+ }
286
+ }
287
+ if (Object.keys(stripped).length > 0) {
288
+ rootArgs.include = stripped
289
+ } else {
290
+ delete rootArgs.include
291
+ }
292
+ }
293
+
294
+ return {
295
+ rootArgs,
296
+ stages: ctx.stages,
297
+ internalFieldPaths: ctx.internalFieldPaths,
298
+ }
299
+ }
@@ -0,0 +1,307 @@
1
+ import type { Request, Response } from 'express'
2
+ import {
3
+ initSSE,
4
+ endSSE,
5
+ startSSEKeepalive,
6
+ sendSSEField,
7
+ sendSSEResult,
8
+ sendSSEError,
9
+ sendSSEProgress,
10
+ runSingleResultSSE,
11
+ setByPath,
12
+ getDelegate,
13
+ getExtendedClient,
14
+ type OperationContext,
15
+ type PrismaDelegate,
16
+ } from './operationRuntime'
17
+ import {
18
+ planAutoInclude,
19
+ type ModelRelationMap,
20
+ type AutoIncludeStage,
21
+ } from './autoIncludePlanner'
22
+ import type { AutoIncludeProgressiveVariantConfig } from './routeConfig'
23
+
24
+ const STAGE_CONCURRENCY = 4
25
+ type IntervalHandle = ReturnType<typeof setInterval>
26
+
27
+ export type RunAutoIncludeOptions = {
28
+ req: Request
29
+ res: Response
30
+ ctx: OperationContext
31
+ args: Record<string, unknown>
32
+ baseOp: 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow'
33
+ modelName: string
34
+ delegateKey: string
35
+ models: Record<string, ModelRelationMap>
36
+ variantConfig: AutoIncludeProgressiveVariantConfig
37
+ coreQueryFn: () => Promise<unknown>
38
+ }
39
+
40
+ function isObject(v: unknown): v is Record<string, unknown> {
41
+ return v !== null && typeof v === 'object' && !Array.isArray(v)
42
+ }
43
+
44
+ function readPath(source: Record<string, unknown>, path: string): unknown {
45
+ if (path === '') return source
46
+ const parts = path.split('.')
47
+ let cursor: unknown = source
48
+ for (const part of parts) {
49
+ if (!isObject(cursor)) return undefined
50
+ cursor = cursor[part]
51
+ }
52
+ return cursor
53
+ }
54
+
55
+ function stripInternalAtScope(
56
+ target: Record<string, unknown>,
57
+ internalPaths: string[],
58
+ scopePath: string,
59
+ ): void {
60
+ for (const fullPath of internalPaths) {
61
+ if (scopePath === '') {
62
+ if (!fullPath.includes('.')) {
63
+ delete target[fullPath]
64
+ }
65
+ continue
66
+ }
67
+ if (!fullPath.startsWith(scopePath + '.')) continue
68
+ const relative = fullPath.slice(scopePath.length + 1)
69
+ if (relative.includes('.')) continue
70
+ delete target[relative]
71
+ }
72
+ }
73
+
74
+ function mergeWhere(
75
+ userWhere: unknown,
76
+ linkFilter: Record<string, unknown>,
77
+ ): Record<string, unknown> {
78
+ if (!isObject(userWhere) || Object.keys(userWhere).length === 0) return linkFilter
79
+ return { AND: [userWhere, linkFilter] }
80
+ }
81
+
82
+ function buildLinkFilter(
83
+ stage: AutoIncludeStage,
84
+ parentValue: Record<string, unknown>,
85
+ ): Record<string, unknown> | null {
86
+ const filter: Record<string, unknown> = {}
87
+ const rel = stage.relationField
88
+ for (let i = 0; i < rel.parentLinkFields.length; i++) {
89
+ const parentKey = rel.parentLinkFields[i]
90
+ const childKey = rel.childLinkFields[i]
91
+ const value = parentValue[parentKey]
92
+ if (value === undefined || value === null) return null
93
+ filter[childKey] = value
94
+ }
95
+ return filter
96
+ }
97
+
98
+ function emptyResultFor(isList: boolean): unknown {
99
+ return isList ? [] : null
100
+ }
101
+
102
+ async function runOneStage(options: {
103
+ extended: unknown
104
+ models: Record<string, ModelRelationMap>
105
+ stage: AutoIncludeStage
106
+ internal: Record<string, unknown>
107
+ publicState: Record<string, unknown>
108
+ internalFieldPaths: string[]
109
+ res: Response
110
+ }): Promise<void> {
111
+ const { extended, models, stage, internal, publicState, internalFieldPaths, res } = options
112
+ if (res.writableEnded || res.destroyed) return
113
+
114
+ const parentRaw = readPath(internal, stage.parentPath)
115
+ if (!isObject(parentRaw)) {
116
+ const empty = emptyResultFor(stage.relationField.isList)
117
+ setByPath(publicState, stage.relationPath, empty)
118
+ sendSSEField(res, stage.relationPath, empty)
119
+ return
120
+ }
121
+
122
+ const linkFilter = buildLinkFilter(stage, parentRaw)
123
+ if (!linkFilter) {
124
+ const empty = emptyResultFor(stage.relationField.isList)
125
+ setByPath(publicState, stage.relationPath, empty)
126
+ sendSSEField(res, stage.relationPath, empty)
127
+ return
128
+ }
129
+
130
+ const targetModel = models[stage.relationField.type]
131
+ if (!targetModel) {
132
+ const empty = emptyResultFor(stage.relationField.isList)
133
+ setByPath(publicState, stage.relationPath, empty)
134
+ sendSSEField(res, stage.relationPath, empty)
135
+ return
136
+ }
137
+
138
+ const finalArgs: Record<string, unknown> = { ...stage.stageArgs }
139
+ finalArgs.where = mergeWhere(stage.stageArgs.where, linkFilter)
140
+
141
+ const delegate: PrismaDelegate = getDelegate(extended, targetModel.delegateKey)
142
+ const method: 'findMany' | 'findFirst' = stage.relationField.isList ? 'findMany' : 'findFirst'
143
+ const result = await delegate[method](finalArgs)
144
+
145
+ setByPath(internal, stage.relationPath, result)
146
+
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
156
+ }
157
+
158
+ setByPath(publicState, stage.relationPath, publicResult)
159
+ sendSSEField(res, stage.relationPath, publicResult)
160
+ }
161
+
162
+ function groupStagesByDepth(stages: AutoIncludeStage[]): AutoIncludeStage[][] {
163
+ const byDepth = new Map<number, AutoIncludeStage[]>()
164
+ for (const s of stages) {
165
+ const arr = byDepth.get(s.depth)
166
+ if (arr) arr.push(s)
167
+ else byDepth.set(s.depth, [s])
168
+ }
169
+ return Array.from(byDepth.keys())
170
+ .sort((a, b) => a - b)
171
+ .map((d) => byDepth.get(d) as AutoIncludeStage[])
172
+ }
173
+
174
+ async function runConcurrent<T>(
175
+ items: T[],
176
+ limit: number,
177
+ fn: (item: T) => Promise<void>,
178
+ ): Promise<void> {
179
+ let index = 0
180
+ const workers: Promise<void>[] = []
181
+ const workerCount = Math.min(limit, items.length)
182
+ for (let w = 0; w < workerCount; w++) {
183
+ workers.push((async () => {
184
+ for (;;) {
185
+ const i = index++
186
+ if (i >= items.length) return
187
+ await fn(items[i])
188
+ }
189
+ })())
190
+ }
191
+ await Promise.all(workers)
192
+ }
193
+
194
+ export async function runAutoIncludeProgressive(
195
+ options: RunAutoIncludeOptions,
196
+ ): Promise<void> {
197
+ const { req, res, ctx, args, baseOp, modelName, delegateKey, models, variantConfig, coreQueryFn } = options
198
+
199
+ if (ctx.guardShape) {
200
+ return runSingleResultSSE({ req, res, coreQueryFn })
201
+ }
202
+
203
+ const plan = planAutoInclude({
204
+ rootModelName: modelName,
205
+ models,
206
+ args,
207
+ })
208
+
209
+ if (plan.unsupportedReason) {
210
+ 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
+ }
219
+ return
220
+ }
221
+ return runSingleResultSSE({ req, res, coreQueryFn })
222
+ }
223
+
224
+ if (plan.stages.length === 0) {
225
+ return runSingleResultSSE({ req, res, coreQueryFn })
226
+ }
227
+
228
+ let keepalive: IntervalHandle | null = null
229
+ try {
230
+ initSSE(res)
231
+ keepalive = startSSEKeepalive(res)
232
+ if (req.destroyed) return
233
+
234
+ const extended = await getExtendedClient(ctx)
235
+ const rootDelegate = getDelegate(extended, delegateKey)
236
+
237
+ let rootResult: unknown
238
+ try {
239
+ rootResult = await rootDelegate[baseOp](plan.rootArgs)
240
+ } catch (err) {
241
+ const code = (err as { code?: string } | null)?.code
242
+ const isOrThrow = baseOp === 'findUniqueOrThrow' || baseOp === 'findFirstOrThrow'
243
+ if (isOrThrow && code === 'P2025') {
244
+ sendSSEError(res, 'Record not found')
245
+ return
246
+ }
247
+ console.error('[auto-progressive] root query failed:', err)
248
+ sendSSEError(res, 'Root query failed')
249
+ return
250
+ }
251
+
252
+ if (res.writableEnded || res.destroyed) return
253
+
254
+ if (rootResult === null || !isObject(rootResult)) {
255
+ sendSSEResult(res, null)
256
+ return
257
+ }
258
+
259
+ const internal: Record<string, unknown> = { ...rootResult }
260
+ const publicRoot: Record<string, unknown> = { ...rootResult }
261
+ stripInternalAtScope(publicRoot, plan.internalFieldPaths, '')
262
+
263
+ const publicState: Record<string, unknown> = { ...publicRoot }
264
+ for (const [k, v] of Object.entries(publicRoot)) {
265
+ sendSSEField(res, k, v)
266
+ }
267
+
268
+ sendSSEProgress(res, 'root', 0, plan.stages.length)
269
+
270
+ const groups = groupStagesByDepth(plan.stages)
271
+ let completed = 0
272
+
273
+ for (const group of groups) {
274
+ if (res.writableEnded || res.destroyed) return
275
+ await runConcurrent(group, STAGE_CONCURRENCY, async (stage) => {
276
+ try {
277
+ await runOneStage({
278
+ extended,
279
+ models,
280
+ stage,
281
+ internal,
282
+ publicState,
283
+ internalFieldPaths: plan.internalFieldPaths,
284
+ res,
285
+ })
286
+ } catch (err) {
287
+ 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)
291
+ }
292
+ completed++
293
+ sendSSEProgress(res, stage.relationPath, completed, plan.stages.length)
294
+ })
295
+ }
296
+
297
+ if (res.writableEnded || res.destroyed) return
298
+ sendSSEResult(res, publicState)
299
+ } catch (err) {
300
+ console.error('[auto-progressive] dispatch error:', err)
301
+ if (!res.writableEnded && !res.destroyed) {
302
+ sendSSEError(res, 'Internal server error')
303
+ }
304
+ } finally {
305
+ endSSE(res, keepalive)
306
+ }
307
+ }