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
@@ -1,3 +1,4 @@
1
+ import { HttpError, LOG_PREFIX } from './errorMapper'
1
2
  import { sanitizeKeys, isPlainObject } from './misc'
2
3
  import type {
3
4
  ProgressivePatch,
@@ -59,123 +60,6 @@ export type PrismaClientLike = {
59
60
  $transaction?: <T>(fn: (tx: PrismaClientLike) => Promise<T>) => Promise<T>
60
61
  }
61
62
 
62
- type PrismaRawClient = {
63
- $queryRawUnsafe?: <T = unknown>(sql: string, ...values: unknown[]) => Promise<T>
64
- }
65
-
66
- export const DISTINCT_COUNT_LIMIT = 100000
67
-
68
- const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
69
-
70
- export class HttpError extends Error {
71
- status: number
72
- constructor(status: number, message: string) {
73
- super(message)
74
- this.name = 'HttpError'
75
- this.status = status
76
- }
77
- }
78
-
79
- const PRISMA_ERROR_MAP: Record<string, { status: number; message: string }> = {
80
- P2000: { status: 400, message: 'Value too long for column' },
81
- P2001: { status: 404, message: 'Record not found' },
82
- P2002: { status: 409, message: 'Unique constraint violation' },
83
- P2003: { status: 400, message: 'Foreign key constraint failed' },
84
- P2004: { status: 400, message: 'Constraint failed on the database' },
85
- P2005: { status: 400, message: 'Invalid field value' },
86
- P2006: { status: 400, message: 'Invalid value provided' },
87
- P2007: { status: 400, message: 'Data validation error' },
88
- P2008: { status: 400, message: 'Failed to parse the query' },
89
- P2009: { status: 400, message: 'Failed to validate the query' },
90
- P2010: { status: 500, message: 'Raw query failed' },
91
- P2011: { status: 400, message: 'Null constraint violation' },
92
- P2012: { status: 400, message: 'Missing required value' },
93
- P2013: { status: 400, message: 'Missing required argument' },
94
- P2014: { status: 400, message: 'Required relation violation' },
95
- P2015: { status: 404, message: 'Related record not found' },
96
- P2016: { status: 400, message: 'Query interpretation error' },
97
- P2017: { status: 400, message: 'Records not connected' },
98
- P2018: { status: 404, message: 'Required connected record not found' },
99
- P2019: { status: 400, message: 'Input error' },
100
- P2020: { status: 400, message: 'Value out of range for the field type' },
101
- P2021: { status: 500, message: 'Table does not exist in the database' },
102
- P2022: { status: 500, message: 'Column does not exist in the database' },
103
- P2023: { status: 500, message: 'Inconsistent column data' },
104
- P2024: { status: 503, message: 'Connection pool timeout' },
105
- P2025: { status: 404, message: 'Record not found' },
106
- P2026: { status: 501, message: 'Feature not supported by the current database provider' },
107
- P2027: { status: 400, message: 'Multiple errors occurred during transaction execution' },
108
- P2028: { status: 500, message: 'Transaction API error' },
109
- P2030: { status: 400, message: 'Cannot find a fulltext index for the search' },
110
- P2033: { status: 400, message: 'Number out of range for the field type' },
111
- P2034: { status: 409, message: 'Transaction conflict, please retry' },
112
- }
113
-
114
- type ErrorShape = {
115
- name?: string
116
- code?: string
117
- message?: string
118
- issues?: unknown
119
- }
120
-
121
- function asErrorShape(error: unknown): ErrorShape {
122
- if (error && typeof error === 'object') return error as ErrorShape
123
- return {}
124
- }
125
-
126
- function isProduction(): boolean {
127
- return typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production'
128
- }
129
-
130
- export function mapError(error: unknown): HttpError {
131
- if (error instanceof HttpError) return error
132
- const e = asErrorShape(error)
133
- const isProd = isProduction()
134
-
135
- if (e.name === 'ShapeError') return new HttpError(400, e.message || 'Shape validation failed')
136
- if (e.name === 'CallerError') return new HttpError(400, e.message || 'Caller validation failed')
137
- if (e.name === 'PolicyError') return new HttpError(403, e.message || 'Policy denied')
138
- if (e.name === 'ZodError') {
139
- const issues = e.issues
140
- const message = Array.isArray(issues)
141
- ? (issues as Array<{ message?: string }>).map((i) => i.message ?? '').filter(Boolean).join('; ')
142
- : (e.message || 'Validation failed')
143
- return new HttpError(400, message)
144
- }
145
- if (typeof e.code === 'string') {
146
- const mapped = PRISMA_ERROR_MAP[e.code]
147
- if (mapped) {
148
- const detail = e.message
149
- const shouldStripDetail = isProd && mapped.status >= 500
150
- return new HttpError(
151
- mapped.status,
152
- !shouldStripDetail && detail ? mapped.message + ': ' + detail : mapped.message,
153
- )
154
- }
155
- if (e.code.startsWith('P')) {
156
- const msg = e.message || 'Database operation failed'
157
- console.warn('[prisma-generator-express] Unmapped Prisma error code:', e.code, msg)
158
- return new HttpError(500, isProd ? 'Internal server error' : msg)
159
- }
160
- }
161
- if (typeof e.name === 'string') {
162
- if (e.name === 'PrismaClientValidationError') return new HttpError(400, e.message || 'Invalid query parameters')
163
- if (e.name === 'PrismaClientKnownRequestError') return new HttpError(400, e.message || 'Database request 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
- }
173
- }
174
- const msg = error instanceof Error ? error.message : String(error)
175
- console.error('[prisma-generator-express] Unhandled error:', error)
176
- return new HttpError(500, isProd ? 'Internal server error' : (msg || 'Internal server error'))
177
- }
178
-
179
63
  type SpeedExtensionFactory = (opts: { postgres?: unknown; sqlite?: unknown; debug?: boolean }) => unknown
180
64
 
181
65
  let _speedExtension: SpeedExtensionFactory | null = null
@@ -191,7 +75,7 @@ const _prismasqlReady = (async () => {
191
75
  } catch (err) {
192
76
  const code = (err as { code?: string } | null)?.code
193
77
  if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') {
194
- console.warn('[prisma-generator-express] prisma-sql initialization failed:', err)
78
+ console.warn(LOG_PREFIX, 'prisma-sql initialization failed:', err)
195
79
  }
196
80
  }
197
81
  })()
@@ -207,12 +91,10 @@ export async function getExtendedClient(ctx: OperationContext): Promise<unknown>
207
91
  if (!_speedExtension) return base
208
92
  const connector = (ctx.postgres ?? ctx.sqlite) as object | undefined
209
93
  if (!connector) return base
210
- if (typeof connector === 'object' && connector !== null) {
211
- const innerMap = _extendedClients.get(connector)
212
- if (innerMap) {
213
- const cached = innerMap.get(base as unknown as object)
214
- if (cached) return cached
215
- }
94
+ const innerMap = _extendedClients.get(connector)
95
+ if (innerMap) {
96
+ const cached = innerMap.get(base as unknown as object)
97
+ if (cached) return cached
216
98
  }
217
99
  try {
218
100
  if (typeof base.$extends !== 'function') return base
@@ -221,14 +103,12 @@ export async function getExtendedClient(ctx: OperationContext): Promise<unknown>
221
103
  sqlite: ctx.sqlite,
222
104
  debug: process.env.DEBUG === 'true',
223
105
  }))
224
- if (typeof connector === 'object' && connector !== null) {
225
- let innerMap = _extendedClients.get(connector)
226
- if (!innerMap) {
227
- innerMap = new WeakMap<object, unknown>()
228
- _extendedClients.set(connector, innerMap)
229
- }
230
- innerMap.set(base as unknown as object, extended)
106
+ let map = _extendedClients.get(connector)
107
+ if (!map) {
108
+ map = new WeakMap<object, unknown>()
109
+ _extendedClients.set(connector, map)
231
110
  }
111
+ map.set(base as unknown as object, extended)
232
112
  return extended
233
113
  } catch (error) {
234
114
  console.warn('[speedExtension] Failed to initialize, using base client:', error)
@@ -260,211 +140,6 @@ export function requireBodyField(body: Record<string, unknown>, field: string):
260
140
  }
261
141
  }
262
142
 
263
- export function applyPaginationLimits(
264
- query: Record<string, unknown>,
265
- config?: PaginationConfig,
266
- hasGuardShape?: boolean,
267
- ): Record<string, unknown> {
268
- if (!config) return query
269
- const result: Record<string, unknown> = { ...query }
270
- if (!hasGuardShape && result.take === undefined && config.defaultLimit !== undefined) {
271
- result.take = config.defaultLimit
272
- }
273
- if (config.maxLimit !== undefined && result.take !== undefined) {
274
- const takeNum = Number(result.take)
275
- if (!Number.isFinite(takeNum)) {
276
- result.take = config.maxLimit
277
- } else if (Math.abs(takeNum) > config.maxLimit) {
278
- result.take = takeNum < 0 ? -config.maxLimit : config.maxLimit
279
- }
280
- }
281
- return result
282
- }
283
-
284
- export function mergePaginationConfig(
285
- base: PaginationConfig | undefined,
286
- override: Partial<PaginationConfig> | undefined,
287
- ): PaginationConfig | undefined {
288
- if (!base && !override) return undefined
289
- return { ...(base ?? {}), ...(override ?? {}) }
290
- }
291
-
292
- export function normalizeDistinct(value: unknown): string[] {
293
- if (typeof value === 'string') return [value]
294
- if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
295
- return []
296
- }
297
-
298
- export function assertGuard(
299
- delegate: PrismaDelegate,
300
- ): asserts delegate is PrismaDelegate & { guard: NonNullable<PrismaDelegate['guard']> } {
301
- if (typeof delegate.guard !== 'function') {
302
- throw new HttpError(
303
- 500,
304
- 'Guard shapes require prisma-guard extension on PrismaClient. Install: npm install prisma-guard, then extend your client with guardExtension().',
305
- )
306
- }
307
- }
308
-
309
- const GUARD_SHAPE_CONFIG_KEYS = new Set([
310
- 'data', 'create', 'update', 'where', 'include', 'select', 'orderBy',
311
- 'cursor', 'take', 'skip', 'distinct', 'having', '_count', '_avg',
312
- '_sum', '_min', '_max', 'by',
313
- ])
314
-
315
- function keepWhereOnly(obj: Record<string, unknown>): Record<string, unknown> {
316
- const result: Record<string, unknown> = {}
317
- if ('where' in obj) result.where = obj.where
318
- return result
319
- }
320
-
321
- type ShapeFn = (...args: unknown[]) => Record<string, unknown>
322
-
323
- export function buildCountShape(
324
- shape: Record<string, unknown> | ShapeFn,
325
- ): Record<string, unknown> | ShapeFn {
326
- if (typeof shape === 'function') {
327
- const fn = shape as ShapeFn
328
- return (...args: unknown[]) => keepWhereOnly(fn(...args))
329
- }
330
- const keys = Object.keys(shape)
331
- const isSingleShape = keys.length === 0 || keys.every((k) => GUARD_SHAPE_CONFIG_KEYS.has(k))
332
- if (isSingleShape) return keepWhereOnly(shape)
333
- const result: Record<string, unknown> = {}
334
- for (const [key, variant] of Object.entries(shape)) {
335
- if (typeof variant === 'function') {
336
- const vfn = variant as ShapeFn
337
- result[key] = (...args: unknown[]) => keepWhereOnly(vfn(...args))
338
- } else if (variant && typeof variant === 'object') {
339
- result[key] = keepWhereOnly(variant as Record<string, unknown>)
340
- } else {
341
- result[key] = variant
342
- }
343
- }
344
- return result
345
- }
346
-
347
- function quoteIdent(name: string): string {
348
- if (!IDENT_RE.test(name)) {
349
- throw new HttpError(400, 'invalid identifier: ' + name)
350
- }
351
- return '"' + name.replace(/"/g, '""') + '"'
352
- }
353
-
354
- function buildMaterializedCountFqn(
355
- source: Extract<PaginationCountSource, { type: 'materializedView' }>,
356
- ): string {
357
- return source.schema
358
- ? quoteIdent(source.schema) + '.' + quoteIdent(source.relation)
359
- : quoteIdent(source.relation)
360
- }
361
-
362
- function buildMaterializedCountWhere(
363
- where: Record<string, unknown> | undefined,
364
- ): { sql: string; values: unknown[] } {
365
- if (!where || Object.keys(where).length === 0) {
366
- return { sql: '', values: [] }
367
- }
368
- const values: unknown[] = []
369
- const clauses: string[] = []
370
- for (const [key, value] of Object.entries(where)) {
371
- if (value === null) {
372
- clauses.push(quoteIdent(key) + ' IS NULL')
373
- continue
374
- }
375
- values.push(value)
376
- clauses.push(quoteIdent(key) + ' = $' + values.length)
377
- }
378
- return { sql: ' WHERE ' + clauses.join(' AND '), values }
379
- }
380
-
381
- export async function countFromMaterializedView(
382
- client: unknown,
383
- source: Extract<PaginationCountSource, { type: 'materializedView' }>,
384
- ): Promise<number> {
385
- const raw = client as PrismaRawClient
386
- if (typeof raw.$queryRawUnsafe !== 'function') {
387
- throw new HttpError(500, 'Materialized count source requires $queryRawUnsafe on the Prisma client')
388
- }
389
- const column = source.column ?? 'total'
390
- const where = buildMaterializedCountWhere(source.where)
391
- const sql =
392
- 'SELECT ' +
393
- quoteIdent(column) +
394
- ' AS "total" FROM ' +
395
- buildMaterializedCountFqn(source) +
396
- where.sql +
397
- ' LIMIT 1'
398
- const rows = await raw.$queryRawUnsafe<Array<{ total: unknown }>>(sql, ...where.values)
399
- const value = rows[0]?.total
400
- const total = Number(value)
401
- if (!Number.isFinite(total)) {
402
- throw new HttpError(500, 'Materialized count source did not return a numeric total')
403
- }
404
- return Math.trunc(total)
405
- }
406
-
407
- export async function countForPagination(
408
- delegate: PrismaDelegate,
409
- query: Record<string, unknown>,
410
- shape: Record<string, unknown> | undefined,
411
- caller: string | undefined,
412
- distinctCountLimit?: number,
413
- countSource?: PaginationCountSource,
414
- rawClient?: unknown,
415
- ): Promise<number> {
416
- if (
417
- countSource &&
418
- countSource.type === 'materializedView' &&
419
- !shape &&
420
- !query.where &&
421
- !query.distinct
422
- ) {
423
- return countFromMaterializedView(rawClient ?? delegate, countSource)
424
- }
425
-
426
- const distinctFields = normalizeDistinct(query.distinct)
427
- const hasDistinct = distinctFields.length > 0
428
- const effectiveLimit = distinctCountLimit ?? DISTINCT_COUNT_LIMIT
429
- const countShape = shape ? buildCountShape(shape) : undefined
430
-
431
- const runCount = async (): Promise<number> => {
432
- const countArgs: Record<string, unknown> = {}
433
- if (query.where) countArgs.where = query.where
434
- if (countShape) {
435
- return (await (delegate.guard as NonNullable<PrismaDelegate['guard']>)(
436
- countShape as Record<string, unknown>,
437
- caller,
438
- ).count(countArgs)) as number
439
- }
440
- return (await delegate.count(countArgs)) as number
441
- }
442
-
443
- if (hasDistinct && shape) return runCount()
444
-
445
- if (hasDistinct) {
446
- const selectField = distinctFields[0]
447
- const distinctArgs: Record<string, unknown> = {
448
- where: query.where,
449
- distinct: distinctFields,
450
- select: { [selectField]: true },
451
- take: effectiveLimit + 1,
452
- }
453
- const results = (await delegate.findMany(distinctArgs)) as unknown[]
454
- if (results.length > effectiveLimit) {
455
- console.warn(
456
- '[prisma-generator-express] Distinct count exceeds ' +
457
- effectiveLimit +
458
- ', falling back to approximate total',
459
- )
460
- return runCount()
461
- }
462
- return results.length
463
- }
464
-
465
- return runCount()
466
- }
467
-
468
143
  export function transformResult(value: unknown): unknown {
469
144
  if (value === null || value === undefined) return value
470
145
  if (typeof value === 'bigint') return value.toString()
@@ -494,273 +169,4 @@ export function transformResult(value: unknown): unknown {
494
169
  return changed ? out : value
495
170
  }
496
171
  return value
497
- }
498
-
499
- export function acceptsEventStream(accept: string | undefined): boolean {
500
- if (!accept) return false
501
- return accept
502
- .toLowerCase()
503
- .split(',')
504
- .some((entry) => entry.split(';')[0].trim() === 'text/event-stream')
505
- }
506
-
507
- const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
508
-
509
- export function setByPath(target: Record<string, unknown>, path: string, value: unknown): boolean {
510
- const parts = path.split('.')
511
- if (parts.length === 0) return false
512
- for (const p of parts) {
513
- if (p === '' || UNSAFE_PATH_SEGMENTS.has(p)) return false
514
- }
515
- let cursor: Record<string, unknown> = target
516
- for (let i = 0; i < parts.length - 1; i++) {
517
- const part = parts[i]
518
- const next = cursor[part]
519
- if (!isPlainObject(next)) {
520
- if (process.env.NODE_ENV !== 'production') {
521
- console.warn(
522
- '[progressive] Dropping patch for "' + path +
523
- '": cannot traverse non-plain-object at segment "' + part + '"',
524
- )
525
- }
526
- return false
527
- }
528
- cursor = next
529
- }
530
- cursor[parts[parts.length - 1]] = value
531
- return true
532
- }
533
-
534
- type EventEmitterLike = {
535
- on?: (event: string, listener: () => void) => unknown
536
- off?: (event: string, listener: () => void) => unknown
537
- removeListener?: (event: string, listener: () => void) => unknown
538
- destroyed?: boolean
539
- }
540
-
541
- type SseWritable = {
542
- statusCode: number
543
- setHeader: (name: string, value: string) => unknown
544
- flushHeaders?: () => unknown
545
- flush?: () => unknown
546
- write: (chunk: string) => unknown
547
- end: () => unknown
548
- writableEnded: boolean
549
- destroyed: boolean
550
- }
551
-
552
- export function removeReqCloseListener(req: EventEmitterLike, listener: () => void): void {
553
- if (typeof req.off === 'function') {
554
- req.off('close', listener)
555
- } else if (typeof req.removeListener === 'function') {
556
- req.removeListener('close', listener)
557
- }
558
- }
559
-
560
- export function initSSE(res: SseWritable): void {
561
- res.statusCode = 200
562
- res.setHeader('Content-Type', 'text/event-stream')
563
- res.setHeader('Cache-Control', 'no-cache, no-transform')
564
- res.setHeader('Connection', 'keep-alive')
565
- res.setHeader('X-Accel-Buffering', 'no')
566
- if (typeof res.flushHeaders === 'function') res.flushHeaders()
567
- }
568
-
569
- export function flushSSE(res: SseWritable): void {
570
- if (typeof res.flush === 'function') {
571
- try { res.flush() } catch {}
572
- }
573
- }
574
-
575
- export function sendSSE(res: SseWritable, payload: unknown): boolean {
576
- if (res.writableEnded || res.destroyed) return false
577
- try {
578
- res.write('data: ' + JSON.stringify(transformResult(payload)) + '\n\n')
579
- flushSSE(res)
580
- return true
581
- } catch (err) {
582
- console.error('[progressive] failed to send SSE event:', err)
583
- return false
584
- }
585
- }
586
-
587
- export function sendSSEProgress(res: SseWritable, stage: string, completed: number, total: number): boolean {
588
- return sendSSE(res, { type: 'progress', stage, completed, total })
589
- }
590
-
591
- export function sendSSEField(res: SseWritable, key: string, value: unknown): boolean {
592
- return sendSSE(res, { type: 'field', key, value })
593
- }
594
-
595
- export function sendSSEResult(res: SseWritable, data: unknown): boolean {
596
- return sendSSE(res, { type: 'result', data })
597
- }
598
-
599
- export function sendSSERootArray(res: SseWritable, rows: unknown[]): boolean {
600
- return sendSSE(res, { type: 'rootArray', data: rows })
601
- }
602
-
603
- export function sendSSERelationBatch(
604
- res: SseWritable,
605
- relationPath: string,
606
- values: unknown[],
607
- ): boolean {
608
- return sendSSE(res, { type: 'relationBatch', relationPath, values })
609
- }
610
-
611
- export function sendSSENestedRelationBatch(
612
- res: SseWritable,
613
- relationPath: string,
614
- depth: number,
615
- attachments: Array<{ locator: Array<number | string>; value: unknown }>,
616
- ): boolean {
617
- return sendSSE(res, { type: 'nestedRelationBatch', relationPath, depth, attachments })
618
- }
619
-
620
- export function sendSSEPageMeta(
621
- res: SseWritable,
622
- total: number,
623
- hasMore: boolean,
624
- ): boolean {
625
- return sendSSE(res, { type: 'pageMeta', total, hasMore })
626
- }
627
-
628
- export function sendSSEError(res: SseWritable, message: string): boolean {
629
- if (res.writableEnded || res.destroyed) return false
630
- try {
631
- res.write('data: ' + JSON.stringify({ type: 'error', message }) + '\n\n')
632
- flushSSE(res)
633
- return true
634
- } catch (err) {
635
- console.error('[progressive] failed to send SSE error event:', err)
636
- return false
637
- }
638
- }
639
-
640
- type IntervalHandle = ReturnType<typeof setInterval>
641
-
642
- export function startSSEKeepalive(res: SseWritable, intervalMs: number = 15000): IntervalHandle {
643
- const handle = setInterval(() => {
644
- if (res.writableEnded || res.destroyed) return
645
- try {
646
- res.write(': keepalive\n\n')
647
- flushSSE(res)
648
- } catch {}
649
- }, intervalMs)
650
- const maybeUnref = (handle as unknown as { unref?: () => void }).unref
651
- if (typeof maybeUnref === 'function') maybeUnref.call(handle)
652
- return handle
653
- }
654
-
655
- export function endSSE(res: SseWritable, keepaliveHandle: IntervalHandle | null): void {
656
- if (keepaliveHandle) {
657
- try { clearInterval(keepaliveHandle) } catch {}
658
- }
659
- if (!res.writableEnded && !res.destroyed) {
660
- try { res.end() } catch {}
661
- }
662
- }
663
-
664
- export function emitTerminalSSEError(res: SseWritable, message: string): void {
665
- let keepalive: IntervalHandle | null = null
666
- try {
667
- initSSE(res)
668
- keepalive = startSSEKeepalive(res)
669
- sendSSEError(res, message)
670
- } finally {
671
- endSSE(res, keepalive)
672
- }
673
- }
674
-
675
- export interface RunSingleResultSSEOptions {
676
- req: EventEmitterLike
677
- res: SseWritable
678
- coreQueryFn: () => Promise<unknown>
679
- }
680
-
681
- export async function runSingleResultSSE(options: RunSingleResultSSEOptions): Promise<void> {
682
- const { req, res, coreQueryFn } = options
683
- let keepalive: IntervalHandle | null = null
684
- try {
685
- initSSE(res)
686
- keepalive = startSSEKeepalive(res)
687
- if (req.destroyed) return
688
- const data = await coreQueryFn()
689
- if (res.writableEnded || res.destroyed) return
690
- sendSSEResult(res, data)
691
- } catch (err) {
692
- console.error('[progressive] single-result error:', err)
693
- if (!res.writableEnded && !res.destroyed) {
694
- sendSSEError(res, mapError(err).message)
695
- }
696
- } finally {
697
- endSSE(res, keepalive)
698
- }
699
- }
700
-
701
- function isStopResult(value: unknown): value is ProgressiveStopResult<unknown> {
702
- return typeof value === 'object' && value !== null && (value as { stop?: unknown }).stop === true
703
- }
704
-
705
- export interface RunProgressiveOptions {
706
- req: EventEmitterLike
707
- res: SseWritable
708
- ctx: unknown
709
- prisma: unknown
710
- variant: string
711
- stages: string[]
712
- stageRegistry: Record<string, ProgressiveStage>
713
- }
714
-
715
- export async function runProgressiveEndpoint(options: RunProgressiveOptions): Promise<void> {
716
- const { req, res, ctx, prisma, variant, stages, stageRegistry } = options
717
- let keepalive: IntervalHandle | null = null
718
- const controller = new AbortController()
719
- const onClose = () => controller.abort()
720
- if (typeof req.on === 'function') req.on('close', onClose)
721
-
722
- const accumulated: Record<string, unknown> = {}
723
- const signal = controller.signal
724
-
725
- try {
726
- initSSE(res)
727
- keepalive = startSSEKeepalive(res)
728
- sendSSEProgress(res, 'start', 0, stages.length)
729
-
730
- for (let i = 0; i < stages.length; i++) {
731
- if (res.writableEnded || res.destroyed || signal.aborted) return
732
- const stageName = stages[i]
733
- const stage = stageRegistry[stageName]
734
- if (!stage) throw new Error('Missing progressive stage: ' + stageName)
735
-
736
- const result = await stage({ ctx, req, res, prisma, variant, accumulated, signal })
737
- if (res.writableEnded || res.destroyed) return
738
-
739
- if (isStopResult(result)) {
740
- sendSSEResult(res, result.data)
741
- return
742
- }
743
-
744
- const patches = Array.isArray(result) ? result : result ? [result] : []
745
- for (const patch of patches) {
746
- if (!patch || typeof patch !== 'object') continue
747
- const p = patch as ProgressivePatch
748
- if (typeof p.key !== 'string') continue
749
- if (!('value' in p)) continue
750
- const applied = setByPath(accumulated, p.key, p.value)
751
- if (applied) sendSSEField(res, p.key, p.value)
752
- }
753
- sendSSEProgress(res, stageName, i + 1, stages.length)
754
- }
755
- if (res.writableEnded || res.destroyed) return
756
- sendSSEResult(res, accumulated)
757
- } catch (err) {
758
- console.error('[progressive] stage error:', err)
759
- if (!res.writableEnded && !res.destroyed) {
760
- sendSSEError(res, mapError(err).message)
761
- }
762
- } finally {
763
- removeReqCloseListener(req, onClose)
764
- endSSE(res, keepalive)
765
- }
766
172
  }