prisma-generator-express 1.60.0 → 1.62.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.
@@ -0,0 +1,379 @@
1
+ import type { Request, Response } from 'express'
2
+ import { HttpError, LOG_PREFIX, mapError } from './errorMapper'
3
+ import {
4
+ sendSSEField,
5
+ sendSSEResult,
6
+ sendSSEError,
7
+ sendSSEProgress,
8
+ runSingleResultSSE,
9
+ emitTerminalSSEError,
10
+ safeSendError,
11
+ withSSE,
12
+ } from './sse'
13
+ import {
14
+ getDelegate,
15
+ getExtendedClient,
16
+ type OperationContext,
17
+ } from './operationRuntime'
18
+ import { isPlainObject } from './misc'
19
+ import { mapLimited } from './concurrency'
20
+ import {
21
+ planGuardedAutoInclude,
22
+ type GuardedAutoIncludePlan,
23
+ type GuardedAutoIncludeStage,
24
+ } from './autoIncludePlannerGuarded'
25
+ import type { ModelRelationMap } from './autoIncludePlanner'
26
+ import type { AutoIncludeProgressiveVariantConfig } from './routeConfig'
27
+
28
+ const STAGE_CONCURRENCY = 4
29
+
30
+ type GuardedBaseOp =
31
+ | 'findUnique'
32
+ | 'findUniqueOrThrow'
33
+ | 'findFirst'
34
+ | 'findFirstOrThrow'
35
+
36
+ const GUARDED_BASE_OPS = new Set<string>([
37
+ 'findUnique',
38
+ 'findUniqueOrThrow',
39
+ 'findFirst',
40
+ 'findFirstOrThrow',
41
+ ])
42
+
43
+ export function isGuardedAutoIncludeBaseOp(op: string): op is GuardedBaseOp {
44
+ return GUARDED_BASE_OPS.has(op)
45
+ }
46
+
47
+ export type RunGuardedAutoIncludeOptions = {
48
+ req: Request
49
+ res: Response
50
+ ctx: OperationContext
51
+ args: Record<string, unknown>
52
+ baseOp: GuardedBaseOp
53
+ modelName: string
54
+ delegateKey: string
55
+ models: Record<string, ModelRelationMap>
56
+ variantConfig: AutoIncludeProgressiveVariantConfig
57
+ coreQueryFn: () => Promise<unknown>
58
+ signal?: AbortSignal
59
+ }
60
+
61
+ type GuardedDelegate = {
62
+ resolve: (body?: unknown) => {
63
+ shape: Record<string, unknown>
64
+ body: Record<string, unknown>
65
+ effectiveReadBody: Record<string, unknown>
66
+ matchedKey: string
67
+ wasDynamic: boolean
68
+ }
69
+ findFirst: (args?: unknown) => Promise<unknown>
70
+ findFirstOrThrow: (args?: unknown) => Promise<unknown>
71
+ findUnique: (args?: unknown) => Promise<unknown>
72
+ findUniqueOrThrow: (args?: unknown) => Promise<unknown>
73
+ findMany: (args?: unknown) => Promise<unknown>
74
+ }
75
+
76
+ function guarded(
77
+ delegate: unknown,
78
+ shape: Record<string, unknown>,
79
+ caller: string | undefined,
80
+ ): GuardedDelegate {
81
+ const d = delegate as {
82
+ guard?: (shape: Record<string, unknown>, caller?: string) => unknown
83
+ }
84
+ if (typeof d.guard !== 'function') {
85
+ throw new HttpError(
86
+ 500,
87
+ 'Guarded auto-include requires prisma-guard extension on PrismaClient.',
88
+ )
89
+ }
90
+ return d.guard(shape, caller) as GuardedDelegate
91
+ }
92
+
93
+ function createClientGoneChecker(res: Response, signal?: AbortSignal): () => boolean {
94
+ return () => signal?.aborted === true || res.writableEnded || res.destroyed
95
+ }
96
+
97
+ function stripInternalAtScope(
98
+ target: Record<string, unknown>,
99
+ internalPaths: string[],
100
+ scopePath: string,
101
+ ): void {
102
+ const prefix = scopePath === '' ? '' : scopePath + '.'
103
+ for (const fullPath of internalPaths) {
104
+ if (!fullPath.startsWith(prefix)) continue
105
+ const relative = fullPath.slice(prefix.length)
106
+ if (relative === '' || relative.includes('.')) continue
107
+ delete target[relative]
108
+ }
109
+ }
110
+
111
+ function buildPublicForStage(
112
+ result: unknown,
113
+ internalFieldPaths: string[],
114
+ scopePath: string,
115
+ ): unknown {
116
+ const process = (item: unknown): unknown => {
117
+ if (!isPlainObject(item)) return item
118
+ const copy: Record<string, unknown> = { ...item }
119
+ stripInternalAtScope(copy, internalFieldPaths, scopePath)
120
+ return copy
121
+ }
122
+ if (Array.isArray(result)) return result.map(process)
123
+ return process(result)
124
+ }
125
+
126
+ function hasClientProjection(body: unknown): boolean {
127
+ if (!isPlainObject(body)) return false
128
+ return 'select' in body || 'include' in body || 'omit' in body
129
+ }
130
+
131
+ function handleGuardedFallback(
132
+ options: RunGuardedAutoIncludeOptions,
133
+ message: string,
134
+ ): Promise<void> {
135
+ if (options.variantConfig.fallback === 'error') {
136
+ emitTerminalSSEError(options.res, message)
137
+ return Promise.resolve()
138
+ }
139
+ return runSingleResultSSE({
140
+ req: options.req,
141
+ res: options.res,
142
+ coreQueryFn: options.coreQueryFn,
143
+ })
144
+ }
145
+
146
+ async function runRootQuery(
147
+ extended: unknown,
148
+ delegateKey: string,
149
+ rootShape: Record<string, unknown>,
150
+ rootArgs: Record<string, unknown>,
151
+ baseOp: GuardedBaseOp,
152
+ caller: string | undefined,
153
+ ): Promise<unknown> {
154
+ const rootDelegate = getDelegate(extended, delegateKey)
155
+ const g = guarded(rootDelegate, rootShape, caller)
156
+ return g[baseOp](rootArgs)
157
+ }
158
+
159
+ async function runStage(
160
+ extended: unknown,
161
+ models: Record<string, ModelRelationMap>,
162
+ stage: GuardedAutoIncludeStage,
163
+ parentValue: unknown,
164
+ caller: string | undefined,
165
+ ): Promise<unknown> {
166
+ const rel = stage.relationField
167
+ const parentKey = rel.parentLinkFields[0]
168
+ const childKey = rel.childLinkFields[0]
169
+
170
+ if (!isPlainObject(parentValue)) {
171
+ return rel.isList ? [] : null
172
+ }
173
+
174
+ const linkVal = parentValue[parentKey]
175
+ if (linkVal === undefined || linkVal === null) {
176
+ return rel.isList ? [] : null
177
+ }
178
+
179
+ const targetModel = models[rel.type]
180
+ if (!targetModel) {
181
+ throw new HttpError(500, 'Target model not in relation metadata: ' + rel.type)
182
+ }
183
+
184
+ const stageArgs: Record<string, unknown> = { ...stage.stageArgs }
185
+
186
+ if (stageArgs.where !== undefined && !isPlainObject(stageArgs.where)) {
187
+ throw new HttpError(
188
+ 500,
189
+ 'Invalid guarded stage where for ' + stage.relationPath,
190
+ )
191
+ }
192
+
193
+ const existingWhere = isPlainObject(stageArgs.where) ? stageArgs.where : {}
194
+ stageArgs.where = {
195
+ ...existingWhere,
196
+ [childKey]: { in: [linkVal] },
197
+ }
198
+
199
+ const stageDelegate = getDelegate(extended, targetModel.delegateKey)
200
+ const g = guarded(stageDelegate, stage.stageShape, caller)
201
+
202
+ if (rel.isList) {
203
+ return g.findMany(stageArgs)
204
+ }
205
+ return g.findFirst(stageArgs)
206
+ }
207
+
208
+ async function runGuardedAutoIncludeSingle(
209
+ options: RunGuardedAutoIncludeOptions,
210
+ plan: GuardedAutoIncludePlan,
211
+ extended: unknown,
212
+ ): Promise<void> {
213
+ const { res, ctx, baseOp, delegateKey, models, signal } = options
214
+ const caller = ctx.guardCaller
215
+ const isClientGone = createClientGoneChecker(res, signal)
216
+
217
+ await withSSE({ res, signal, label: 'guarded-single' }, async () => {
218
+ if (isClientGone()) return
219
+
220
+ let rootResult: unknown
221
+ try {
222
+ rootResult = await runRootQuery(
223
+ extended,
224
+ delegateKey,
225
+ plan.rootShape,
226
+ plan.rootArgs,
227
+ baseOp,
228
+ caller,
229
+ )
230
+ } catch (err) {
231
+ if (isClientGone()) return
232
+ console.error(LOG_PREFIX, 'guarded root query failed:', err)
233
+ sendSSEError(res, mapError(err).message)
234
+ return
235
+ }
236
+
237
+ if (isClientGone()) return
238
+
239
+ if (rootResult === null || !isPlainObject(rootResult)) {
240
+ sendSSEResult(res, rootResult)
241
+ return
242
+ }
243
+
244
+ const internal: Record<string, unknown> = { ...rootResult }
245
+ const publicRoot: Record<string, unknown> = { ...rootResult }
246
+ stripInternalAtScope(publicRoot, plan.internalFieldPaths, '')
247
+
248
+ const publicState: Record<string, unknown> = { ...publicRoot }
249
+ for (const [k, v] of Object.entries(publicRoot)) {
250
+ if (isClientGone()) return
251
+ const ok = sendSSEField(res, k, v)
252
+ if (!ok) return
253
+ }
254
+
255
+ if (isClientGone()) return
256
+ const okStart = sendSSEProgress(res, 'root', 0, plan.stages.length)
257
+ if (!okStart) return
258
+
259
+ let completed = 0
260
+ let stageErrorMessage: string | null = null
261
+ const isAborted = () =>
262
+ stageErrorMessage !== null ||
263
+ signal?.aborted === true ||
264
+ res.writableEnded ||
265
+ res.destroyed
266
+
267
+ await mapLimited(plan.stages, STAGE_CONCURRENCY, async (stage) => {
268
+ if (isAborted()) return
269
+ try {
270
+ const stageResult = await runStage(
271
+ extended,
272
+ models,
273
+ stage,
274
+ internal,
275
+ caller,
276
+ )
277
+
278
+ if (isAborted()) return
279
+
280
+ internal[stage.relationName] = stageResult
281
+ const publicVal = buildPublicForStage(
282
+ stageResult,
283
+ plan.internalFieldPaths,
284
+ stage.relationPath,
285
+ )
286
+ publicState[stage.relationName] = publicVal
287
+
288
+ const okField = sendSSEField(res, stage.relationName, publicVal)
289
+ if (!okField) return
290
+ } catch (err) {
291
+ if (isAborted()) return
292
+ console.error(LOG_PREFIX, 'guarded stage failed:', stage.relationPath, err)
293
+ stageErrorMessage = mapError(err).message
294
+ return
295
+ }
296
+
297
+ if (isAborted()) return
298
+ completed++
299
+ const okProg = sendSSEProgress(res, stage.relationPath, completed, plan.stages.length)
300
+ if (!okProg) return
301
+ })
302
+
303
+ if (isClientGone()) return
304
+
305
+ if (stageErrorMessage) {
306
+ safeSendError(res, stageErrorMessage)
307
+ return
308
+ }
309
+
310
+ if (res.writableEnded || res.destroyed) return
311
+ sendSSEResult(res, publicState)
312
+ })
313
+ }
314
+
315
+ export async function runGuardedAutoIncludeProgressive(
316
+ options: RunGuardedAutoIncludeOptions,
317
+ ): Promise<void> {
318
+ if (hasClientProjection(options.args)) {
319
+ return handleGuardedFallback(
320
+ options,
321
+ 'guarded auto-progressive fallback: client projection rejected under guarded MVP',
322
+ )
323
+ }
324
+
325
+ const guardShape = options.ctx.guardShape
326
+ if (!guardShape) {
327
+ return handleGuardedFallback(
328
+ options,
329
+ 'guarded auto-progressive fallback: guardShape missing',
330
+ )
331
+ }
332
+
333
+ let extended: unknown
334
+ try {
335
+ extended = await getExtendedClient(options.ctx)
336
+ } catch (err) {
337
+ return handleGuardedFallback(
338
+ options,
339
+ 'guarded auto-progressive fallback: getExtendedClient failed: ' + mapError(err).message,
340
+ )
341
+ }
342
+
343
+ let resolved: {
344
+ shape: Record<string, unknown>
345
+ body: Record<string, unknown>
346
+ effectiveReadBody: Record<string, unknown>
347
+ }
348
+ try {
349
+ const rootDelegate = getDelegate(extended, options.delegateKey)
350
+ const g = guarded(rootDelegate, guardShape, options.ctx.guardCaller)
351
+ resolved = g.resolve(options.args)
352
+ } catch (err) {
353
+ return handleGuardedFallback(
354
+ options,
355
+ 'guarded auto-progressive fallback: resolve failed: ' + mapError(err).message,
356
+ )
357
+ }
358
+
359
+ const plan = planGuardedAutoInclude({
360
+ rootModelName: options.modelName,
361
+ models: options.models,
362
+ effectiveReadBody: resolved.effectiveReadBody,
363
+ shape: resolved.shape,
364
+ })
365
+
366
+ if (plan.unsupportedReason) {
367
+ return handleGuardedFallback(options, plan.unsupportedReason)
368
+ }
369
+
370
+ if (plan.stages.length === 0) {
371
+ return runSingleResultSSE({
372
+ req: options.req,
373
+ res: options.res,
374
+ coreQueryFn: options.coreQueryFn,
375
+ })
376
+ }
377
+
378
+ return runGuardedAutoIncludeSingle(options, plan, extended)
379
+ }
@@ -24,7 +24,9 @@ const SHARED_FILES = [
24
24
 
25
25
  const EXPRESS_ONLY_FILES = [
26
26
  'autoIncludePlanner.ts',
27
+ 'autoIncludePlannerGuarded.ts',
27
28
  'autoIncludeRuntime.ts',
29
+ 'autoIncludeRuntimeGuarded.ts',
28
30
  'materializedRouter.ts',
29
31
  ]
30
32