prisma-generator-express 1.42.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 (53) hide show
  1. package/dist/generators/generateFastifyHandler.d.ts +2 -0
  2. package/dist/generators/generateFastifyHandler.js +21 -6
  3. package/dist/generators/generateFastifyHandler.js.map +1 -1
  4. package/dist/generators/generateHonoHandler.d.ts +2 -0
  5. package/dist/generators/generateHonoHandler.js +8 -6
  6. package/dist/generators/generateHonoHandler.js.map +1 -1
  7. package/dist/generators/generateOperationCore.d.ts +0 -1
  8. package/dist/generators/generateOperationCore.js +34 -546
  9. package/dist/generators/generateOperationCore.js.map +1 -1
  10. package/dist/generators/generateRelationMeta.d.ts +13 -0
  11. package/dist/generators/generateRelationMeta.js +110 -0
  12. package/dist/generators/generateRelationMeta.js.map +1 -0
  13. package/dist/generators/generateRouteConfigType.js +13 -5
  14. package/dist/generators/generateRouteConfigType.js.map +1 -1
  15. package/dist/generators/generateRouter.js +83 -19
  16. package/dist/generators/generateRouter.js.map +1 -1
  17. package/dist/generators/generateRouterFastify.js +127 -384
  18. package/dist/generators/generateRouterFastify.js.map +1 -1
  19. package/dist/generators/generateRouterHono.js +48 -36
  20. package/dist/generators/generateRouterHono.js.map +1 -1
  21. package/dist/generators/generateUnifiedHandler.d.ts +2 -0
  22. package/dist/generators/generateUnifiedHandler.js +28 -10
  23. package/dist/generators/generateUnifiedHandler.js.map +1 -1
  24. package/dist/generators/generateUnifiedScalarUI.d.ts +2 -0
  25. package/dist/generators/generateUnifiedScalarUI.js +19 -16
  26. package/dist/generators/generateUnifiedScalarUI.js.map +1 -1
  27. package/dist/index.js +21 -5
  28. package/dist/index.js.map +1 -1
  29. package/dist/utils/copyFiles.js +12 -0
  30. package/dist/utils/copyFiles.js.map +1 -1
  31. package/dist/utils/writeFileSafely.js +2 -2
  32. package/dist/utils/writeFileSafely.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/copy/autoIncludePlanner.ts +354 -0
  35. package/src/copy/autoIncludeRuntime.ts +362 -0
  36. package/src/copy/operationRuntime.ts +614 -0
  37. package/src/copy/routeConfig.express.ts +5 -3
  38. package/src/copy/routeConfig.fastify.ts +2 -2
  39. package/src/copy/routeConfig.hono.ts +3 -3
  40. package/src/copy/routeConfig.ts +20 -9
  41. package/src/generators/generateFastifyHandler.ts +23 -6
  42. package/src/generators/generateHonoHandler.ts +10 -6
  43. package/src/generators/generateOperationCore.ts +34 -546
  44. package/src/generators/generateRelationMeta.ts +160 -0
  45. package/src/generators/generateRouteConfigType.ts +13 -6
  46. package/src/generators/generateRouter.ts +83 -19
  47. package/src/generators/generateRouterFastify.ts +127 -384
  48. package/src/generators/generateRouterHono.ts +48 -36
  49. package/src/generators/generateUnifiedHandler.ts +30 -10
  50. package/src/generators/generateUnifiedScalarUI.ts +21 -16
  51. package/src/index.ts +31 -13
  52. package/src/utils/copyFiles.ts +13 -0
  53. package/src/utils/writeFileSafely.ts +2 -2
@@ -0,0 +1,614 @@
1
+ import { sanitizeKeys } from './misc'
2
+ import type {
3
+ ProgressivePatch,
4
+ ProgressiveStopResult,
5
+ ProgressiveStageResult,
6
+ ProgressiveStageContext,
7
+ ProgressiveStage,
8
+ } from './routeConfig'
9
+
10
+ export type {
11
+ ProgressivePatch,
12
+ ProgressiveStopResult,
13
+ ProgressiveStageResult,
14
+ ProgressiveStageContext,
15
+ ProgressiveStage,
16
+ }
17
+
18
+ export interface PaginationConfig {
19
+ defaultLimit?: number
20
+ maxLimit?: number
21
+ distinctCountLimit?: number
22
+ }
23
+
24
+ export interface OperationContext {
25
+ prisma: unknown
26
+ postgres?: unknown
27
+ sqlite?: unknown
28
+ parsedQuery?: Record<string, unknown>
29
+ body?: unknown
30
+ guardShape?: Record<string, unknown>
31
+ guardCaller?: string
32
+ paginationConfig?: PaginationConfig
33
+ }
34
+
35
+ export type PrismaDelegate = {
36
+ findMany: (args?: unknown) => Promise<unknown>
37
+ findFirst: (args?: unknown) => Promise<unknown>
38
+ findUnique: (args?: unknown) => Promise<unknown>
39
+ findUniqueOrThrow: (args?: unknown) => Promise<unknown>
40
+ findFirstOrThrow: (args?: unknown) => Promise<unknown>
41
+ create: (args?: unknown) => Promise<unknown>
42
+ createMany: (args?: unknown) => Promise<unknown>
43
+ createManyAndReturn: (args?: unknown) => Promise<unknown>
44
+ update: (args?: unknown) => Promise<unknown>
45
+ updateMany: (args?: unknown) => Promise<unknown>
46
+ updateManyAndReturn: (args?: unknown) => Promise<unknown>
47
+ upsert: (args?: unknown) => Promise<unknown>
48
+ delete: (args?: unknown) => Promise<unknown>
49
+ deleteMany: (args?: unknown) => Promise<unknown>
50
+ count: (args?: unknown) => Promise<unknown>
51
+ aggregate: (args?: unknown) => Promise<unknown>
52
+ groupBy: (args?: unknown) => Promise<unknown>
53
+ guard?: (shape: Record<string, unknown>, caller?: string) => PrismaDelegate
54
+ }
55
+
56
+ export type PrismaClientLike = {
57
+ $extends?: (extension: unknown) => PrismaClientLike
58
+ $transaction?: <T>(fn: (tx: PrismaClientLike) => Promise<T>) => Promise<T>
59
+ }
60
+
61
+ export const DISTINCT_COUNT_LIMIT = 100000
62
+
63
+ export class HttpError extends Error {
64
+ status: number
65
+ constructor(status: number, message: string) {
66
+ super(message)
67
+ this.name = 'HttpError'
68
+ this.status = status
69
+ }
70
+ }
71
+
72
+ const PRISMA_ERROR_MAP: Record<string, { status: number; message: string }> = {
73
+ P2000: { status: 400, message: 'Value too long for column' },
74
+ P2001: { status: 404, message: 'Record not found' },
75
+ P2002: { status: 409, message: 'Unique constraint violation' },
76
+ P2003: { status: 400, message: 'Foreign key constraint failed' },
77
+ P2004: { status: 400, message: 'Constraint failed on the database' },
78
+ P2005: { status: 400, message: 'Invalid field value' },
79
+ P2006: { status: 400, message: 'Invalid value provided' },
80
+ P2007: { status: 400, message: 'Data validation error' },
81
+ P2008: { status: 400, message: 'Failed to parse the query' },
82
+ P2009: { status: 400, message: 'Failed to validate the query' },
83
+ P2010: { status: 500, message: 'Raw query failed' },
84
+ P2011: { status: 400, message: 'Null constraint violation' },
85
+ P2012: { status: 400, message: 'Missing required value' },
86
+ P2013: { status: 400, message: 'Missing required argument' },
87
+ P2014: { status: 400, message: 'Required relation violation' },
88
+ P2015: { status: 404, message: 'Related record not found' },
89
+ P2016: { status: 400, message: 'Query interpretation error' },
90
+ P2017: { status: 400, message: 'Records not connected' },
91
+ P2018: { status: 404, message: 'Required connected record not found' },
92
+ P2019: { status: 400, message: 'Input error' },
93
+ P2020: { status: 400, message: 'Value out of range for the field type' },
94
+ P2021: { status: 500, message: 'Table does not exist in the database' },
95
+ P2022: { status: 500, message: 'Column does not exist in the database' },
96
+ P2023: { status: 500, message: 'Inconsistent column data' },
97
+ P2024: { status: 503, message: 'Connection pool timeout' },
98
+ P2025: { status: 404, message: 'Record not found' },
99
+ P2026: { status: 501, message: 'Feature not supported by the current database provider' },
100
+ P2028: { status: 500, message: 'Transaction API error' },
101
+ P2030: { status: 400, message: 'Cannot find a fulltext index for the search' },
102
+ P2033: { status: 400, message: 'Number out of range for the field type' },
103
+ P2034: { status: 409, message: 'Transaction conflict, please retry' },
104
+ }
105
+
106
+ type ErrorShape = {
107
+ name?: string
108
+ code?: string
109
+ message?: string
110
+ issues?: unknown
111
+ }
112
+
113
+ function asErrorShape(error: unknown): ErrorShape {
114
+ if (error && typeof error === 'object') return error as ErrorShape
115
+ return {}
116
+ }
117
+
118
+ export function mapError(error: unknown): HttpError {
119
+ if (error instanceof HttpError) return error
120
+ const e = asErrorShape(error)
121
+ if (e.name === 'ShapeError') return new HttpError(400, e.message || 'Shape validation failed')
122
+ if (e.name === 'CallerError') return new HttpError(400, e.message || 'Caller validation failed')
123
+ if (e.name === 'PolicyError') return new HttpError(403, e.message || 'Policy denied')
124
+ if (e.name === 'ZodError') {
125
+ const issues = e.issues
126
+ const message = Array.isArray(issues)
127
+ ? (issues as Array<{ message?: string }>).map((i) => i.message ?? '').filter(Boolean).join('; ')
128
+ : (e.message || 'Validation failed')
129
+ return new HttpError(400, message)
130
+ }
131
+ if (typeof e.code === 'string') {
132
+ const mapped = PRISMA_ERROR_MAP[e.code]
133
+ if (mapped) {
134
+ const detail = e.message
135
+ return new HttpError(mapped.status, detail ? mapped.message + ': ' + detail : mapped.message)
136
+ }
137
+ if (e.code.startsWith('P')) {
138
+ const msg = e.message || 'Database operation failed'
139
+ console.warn('[prisma-generator-express] Unmapped Prisma error code:', e.code, msg)
140
+ return new HttpError(500, msg)
141
+ }
142
+ }
143
+ if (typeof e.name === 'string') {
144
+ if (e.name === 'PrismaClientValidationError') return new HttpError(400, e.message || 'Invalid query parameters')
145
+ if (e.name === 'PrismaClientKnownRequestError') return new HttpError(400, e.message || 'Database request error')
146
+ if (e.name === 'PrismaClientInitializationError') return new HttpError(503, e.message || 'Database connection failed')
147
+ if (e.name === 'PrismaClientRustPanicError') return new HttpError(500, e.message || 'Internal database engine error')
148
+ if (e.name === 'PrismaClientUnknownRequestError') return new HttpError(500, e.message || 'Unknown database error')
149
+ }
150
+ const msg = error instanceof Error ? error.message : String(error)
151
+ console.error('[prisma-generator-express] Unhandled error:', error)
152
+ return new HttpError(500, msg || 'Internal server error')
153
+ }
154
+
155
+ type SpeedExtensionFactory = (opts: { postgres?: unknown; sqlite?: unknown; debug?: boolean }) => unknown
156
+
157
+ let _speedExtension: SpeedExtensionFactory | null = null
158
+
159
+ const _prismasqlModule = 'prisma-' + 'sql'
160
+ const _prismasqlReady = (async () => {
161
+ try {
162
+ const mod = (await import(_prismasqlModule)) as {
163
+ speedExtension?: SpeedExtensionFactory
164
+ default?: { speedExtension?: SpeedExtensionFactory }
165
+ }
166
+ _speedExtension = mod.speedExtension ?? mod.default?.speedExtension ?? null
167
+ } catch (err) {
168
+ const code = (err as { code?: string } | null)?.code
169
+ if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') {
170
+ console.warn('[prisma-generator-express] prisma-sql initialization failed:', err)
171
+ }
172
+ }
173
+ })()
174
+
175
+ const _extendedClients = new WeakMap<object, WeakMap<object, unknown>>()
176
+
177
+ export async function getExtendedClient(ctx: OperationContext): Promise<unknown> {
178
+ const base = ctx.prisma as PrismaClientLike | null | undefined
179
+ if (!base) {
180
+ throw new HttpError(500, 'PrismaClient not found on request. Set req.prisma in middleware.')
181
+ }
182
+ await _prismasqlReady
183
+ if (!_speedExtension) return base
184
+ const connector = (ctx.postgres ?? ctx.sqlite) as object | undefined
185
+ if (!connector) return base
186
+ if (typeof connector === 'object' && connector !== null) {
187
+ const innerMap = _extendedClients.get(connector)
188
+ if (innerMap) {
189
+ const cached = innerMap.get(base as unknown as object)
190
+ if (cached) return cached
191
+ }
192
+ }
193
+ try {
194
+ if (typeof base.$extends !== 'function') return base
195
+ const extended = base.$extends(_speedExtension({
196
+ postgres: ctx.postgres,
197
+ sqlite: ctx.sqlite,
198
+ debug: process.env.DEBUG === 'true',
199
+ }))
200
+ if (typeof connector === 'object' && connector !== null) {
201
+ let innerMap = _extendedClients.get(connector)
202
+ if (!innerMap) {
203
+ innerMap = new WeakMap<object, unknown>()
204
+ _extendedClients.set(connector, innerMap)
205
+ }
206
+ innerMap.set(base as unknown as object, extended)
207
+ }
208
+ return extended
209
+ } catch (error) {
210
+ console.warn('[speedExtension] Failed to initialize, using base client:', error)
211
+ return base
212
+ }
213
+ }
214
+
215
+ export function getDelegate(client: unknown, key: string): PrismaDelegate {
216
+ if (!client || typeof client !== 'object') {
217
+ throw new HttpError(500, 'PrismaClient is not a valid object')
218
+ }
219
+ const delegate = (client as Record<string, unknown>)[key]
220
+ if (!delegate || typeof delegate !== 'object') {
221
+ throw new HttpError(500, 'Prisma delegate not found: ' + key)
222
+ }
223
+ return delegate as PrismaDelegate
224
+ }
225
+
226
+ export function validateBody(body: unknown): Record<string, unknown> {
227
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
228
+ throw new HttpError(400, 'Request body must be a JSON object')
229
+ }
230
+ return sanitizeKeys(body as Record<string, unknown>)
231
+ }
232
+
233
+ export function requireBodyField(body: Record<string, unknown>, field: string): void {
234
+ if (!(field in body) || body[field] === undefined) {
235
+ throw new HttpError(400, 'Missing required field: ' + field)
236
+ }
237
+ }
238
+
239
+ export function applyPaginationLimits(
240
+ query: Record<string, unknown>,
241
+ config?: PaginationConfig,
242
+ ): Record<string, unknown> {
243
+ if (!config) return query
244
+ const result: Record<string, unknown> = { ...query }
245
+ if (result.take === undefined && config.defaultLimit !== undefined) {
246
+ result.take = config.defaultLimit
247
+ }
248
+ if (config.maxLimit !== undefined && result.take !== undefined) {
249
+ const takeNum = Number(result.take)
250
+ if (Math.abs(takeNum) > config.maxLimit) {
251
+ result.take = takeNum < 0 ? -config.maxLimit : config.maxLimit
252
+ }
253
+ }
254
+ return result
255
+ }
256
+
257
+ export function normalizeDistinct(value: unknown): string[] {
258
+ if (typeof value === 'string') return [value]
259
+ if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
260
+ return []
261
+ }
262
+
263
+ export function assertGuard(
264
+ delegate: PrismaDelegate,
265
+ ): asserts delegate is PrismaDelegate & { guard: NonNullable<PrismaDelegate['guard']> } {
266
+ if (typeof delegate.guard !== 'function') {
267
+ throw new HttpError(500, 'Guard shapes require prisma-guard extension on PrismaClient.')
268
+ }
269
+ }
270
+
271
+ const GUARD_SHAPE_CONFIG_KEYS = new Set([
272
+ 'data', 'create', 'update', 'where', 'include', 'select', 'orderBy',
273
+ 'cursor', 'take', 'skip', 'distinct', 'having', '_count', '_avg',
274
+ '_sum', '_min', '_max', 'by',
275
+ ])
276
+
277
+ function keepWhereOnly(obj: Record<string, unknown>): Record<string, unknown> {
278
+ const result: Record<string, unknown> = {}
279
+ if ('where' in obj) result.where = obj.where
280
+ return result
281
+ }
282
+
283
+ type ShapeFn = (...args: unknown[]) => Record<string, unknown>
284
+
285
+ export function buildCountShape(
286
+ shape: Record<string, unknown> | ShapeFn,
287
+ ): Record<string, unknown> | ShapeFn {
288
+ if (typeof shape === 'function') {
289
+ const fn = shape as ShapeFn
290
+ return (...args: unknown[]) => keepWhereOnly(fn(...args))
291
+ }
292
+ const keys = Object.keys(shape)
293
+ const isSingleShape = keys.length === 0 || keys.every((k) => GUARD_SHAPE_CONFIG_KEYS.has(k))
294
+ if (isSingleShape) return keepWhereOnly(shape)
295
+ const result: Record<string, unknown> = {}
296
+ for (const [key, variant] of Object.entries(shape)) {
297
+ if (typeof variant === 'function') {
298
+ const vfn = variant as ShapeFn
299
+ result[key] = (...args: unknown[]) => keepWhereOnly(vfn(...args))
300
+ } else if (variant && typeof variant === 'object') {
301
+ result[key] = keepWhereOnly(variant as Record<string, unknown>)
302
+ } else {
303
+ result[key] = variant
304
+ }
305
+ }
306
+ return result
307
+ }
308
+
309
+ export async function countForPagination(
310
+ delegate: PrismaDelegate,
311
+ query: Record<string, unknown>,
312
+ shape: Record<string, unknown> | undefined,
313
+ caller: string | undefined,
314
+ distinctCountLimit?: number,
315
+ ): Promise<number> {
316
+ const distinctFields = normalizeDistinct(query.distinct)
317
+ const hasDistinct = distinctFields.length > 0
318
+ const effectiveLimit = distinctCountLimit ?? DISTINCT_COUNT_LIMIT
319
+ const countShape = shape ? buildCountShape(shape) : undefined
320
+
321
+ if (hasDistinct) {
322
+ const selectField = distinctFields[0]
323
+ const distinctArgs: Record<string, unknown> = {
324
+ where: query.where,
325
+ distinct: distinctFields,
326
+ select: { [selectField]: true },
327
+ take: effectiveLimit + 1,
328
+ }
329
+ const results = shape
330
+ ? await (delegate.guard as NonNullable<PrismaDelegate['guard']>)(shape, caller).findMany(distinctArgs)
331
+ : await delegate.findMany(distinctArgs)
332
+ const resultArray = results as unknown[]
333
+ if (resultArray.length > effectiveLimit) {
334
+ console.warn('[prisma-generator-express] Distinct count exceeds ' + effectiveLimit + ', falling back to approximate total')
335
+ const countArgs: Record<string, unknown> = {}
336
+ if (query.where) countArgs.where = query.where
337
+ const total = countShape
338
+ ? await (delegate.guard as NonNullable<PrismaDelegate['guard']>)(countShape as Record<string, unknown>, caller).count(countArgs)
339
+ : await delegate.count(countArgs)
340
+ return total as number
341
+ }
342
+ return resultArray.length
343
+ }
344
+
345
+ const countArgs: Record<string, unknown> = {}
346
+ if (query.where) countArgs.where = query.where
347
+ const total = countShape
348
+ ? await (delegate.guard as NonNullable<PrismaDelegate['guard']>)(countShape as Record<string, unknown>, caller).count(countArgs)
349
+ : await delegate.count(countArgs)
350
+ return total as number
351
+ }
352
+
353
+ export function transformResult(value: unknown): unknown {
354
+ if (value === null || value === undefined) return value
355
+ if (typeof value === 'bigint') return value.toString()
356
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) return value.toString('base64')
357
+ if (value instanceof Uint8Array) return Buffer.from(value).toString('base64')
358
+ if (value instanceof Date) return value
359
+ if (Array.isArray(value)) return value.map(transformResult)
360
+ if (typeof value === 'object') {
361
+ const proto = Object.getPrototypeOf(value)
362
+ if (proto !== Object.prototype && proto !== null) return value
363
+ const out: Record<string, unknown> = {}
364
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
365
+ out[k] = transformResult(v)
366
+ }
367
+ return out
368
+ }
369
+ return value
370
+ }
371
+
372
+ export function acceptsEventStream(accept: string | undefined): boolean {
373
+ if (!accept) return false
374
+ return accept.toLowerCase().includes('text/event-stream')
375
+ }
376
+
377
+ const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype'])
378
+
379
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
380
+ if (value === null || typeof value !== 'object') return false
381
+ if (Array.isArray(value)) return false
382
+ const proto = Object.getPrototypeOf(value)
383
+ return proto === Object.prototype || proto === null
384
+ }
385
+
386
+ export function setByPath(target: Record<string, unknown>, path: string, value: unknown): boolean {
387
+ const parts = path.split('.')
388
+ if (parts.length === 0) return false
389
+ for (const p of parts) {
390
+ if (p === '' || UNSAFE_PATH_SEGMENTS.has(p)) return false
391
+ }
392
+ let cursor: Record<string, unknown> = target
393
+ for (let i = 0; i < parts.length - 1; i++) {
394
+ const part = parts[i]
395
+ const next = cursor[part]
396
+ if (!isPlainObject(next)) {
397
+ if (process.env.NODE_ENV !== 'production') {
398
+ console.warn(
399
+ '[progressive] Dropping patch for "' + path +
400
+ '": cannot traverse non-plain-object at segment "' + part + '"',
401
+ )
402
+ }
403
+ return false
404
+ }
405
+ cursor = next
406
+ }
407
+ cursor[parts[parts.length - 1]] = value
408
+ return true
409
+ }
410
+
411
+ type EventEmitterLike = {
412
+ on?: (event: string, listener: () => void) => unknown
413
+ off?: (event: string, listener: () => void) => unknown
414
+ removeListener?: (event: string, listener: () => void) => unknown
415
+ destroyed?: boolean
416
+ }
417
+
418
+ type SseWritable = {
419
+ statusCode: number
420
+ setHeader: (name: string, value: string) => unknown
421
+ flushHeaders?: () => unknown
422
+ flush?: () => unknown
423
+ write: (chunk: string) => unknown
424
+ end: () => unknown
425
+ writableEnded: boolean
426
+ destroyed: boolean
427
+ }
428
+
429
+ export function removeReqCloseListener(req: EventEmitterLike, listener: () => void): void {
430
+ if (typeof req.off === 'function') {
431
+ req.off('close', listener)
432
+ } else if (typeof req.removeListener === 'function') {
433
+ req.removeListener('close', listener)
434
+ }
435
+ }
436
+
437
+ export function initSSE(res: SseWritable): void {
438
+ res.statusCode = 200
439
+ res.setHeader('Content-Type', 'text/event-stream')
440
+ res.setHeader('Cache-Control', 'no-cache, no-transform')
441
+ res.setHeader('Connection', 'keep-alive')
442
+ res.setHeader('X-Accel-Buffering', 'no')
443
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
444
+ }
445
+
446
+ export function flushSSE(res: SseWritable): void {
447
+ if (typeof res.flush === 'function') {
448
+ try { res.flush() } catch { /* ignore */ }
449
+ }
450
+ }
451
+
452
+ export function sendSSE(res: SseWritable, payload: unknown): boolean {
453
+ if (res.writableEnded || res.destroyed) return false
454
+ try {
455
+ res.write('data: ' + JSON.stringify(transformResult(payload)) + '\n\n')
456
+ flushSSE(res)
457
+ return true
458
+ } catch (err) {
459
+ console.error('[progressive] failed to send SSE event:', err)
460
+ return false
461
+ }
462
+ }
463
+
464
+ export function sendSSEProgress(res: SseWritable, stage: string, completed: number, total: number): boolean {
465
+ return sendSSE(res, { type: 'progress', stage, completed, total })
466
+ }
467
+
468
+ export function sendSSEField(res: SseWritable, key: string, value: unknown): boolean {
469
+ return sendSSE(res, { type: 'field', key, value })
470
+ }
471
+
472
+ export function sendSSEResult(res: SseWritable, data: unknown): boolean {
473
+ return sendSSE(res, { type: 'result', data })
474
+ }
475
+
476
+ export function sendSSEError(res: SseWritable, message: string): boolean {
477
+ if (res.writableEnded || res.destroyed) return false
478
+ try {
479
+ res.write('data: ' + JSON.stringify({ type: 'error', message }) + '\n\n')
480
+ flushSSE(res)
481
+ return true
482
+ } catch (err) {
483
+ console.error('[progressive] failed to send SSE error event:', err)
484
+ return false
485
+ }
486
+ }
487
+
488
+ type IntervalHandle = ReturnType<typeof setInterval>
489
+
490
+ export function startSSEKeepalive(res: SseWritable, intervalMs: number = 15000): IntervalHandle {
491
+ const handle = setInterval(() => {
492
+ if (res.writableEnded || res.destroyed) return
493
+ try {
494
+ res.write(': keepalive\n\n')
495
+ flushSSE(res)
496
+ } catch { /* ignore */ }
497
+ }, intervalMs)
498
+ const maybeUnref = (handle as unknown as { unref?: () => void }).unref
499
+ if (typeof maybeUnref === 'function') maybeUnref.call(handle)
500
+ return handle
501
+ }
502
+
503
+ export function endSSE(res: SseWritable, keepaliveHandle: IntervalHandle | null): void {
504
+ if (keepaliveHandle) {
505
+ try { clearInterval(keepaliveHandle) } catch { /* ignore */ }
506
+ }
507
+ if (!res.writableEnded && !res.destroyed) {
508
+ try { res.end() } catch { /* ignore */ }
509
+ }
510
+ }
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
+
523
+ export interface RunSingleResultSSEOptions {
524
+ req: EventEmitterLike
525
+ res: SseWritable
526
+ coreQueryFn: () => Promise<unknown>
527
+ }
528
+
529
+ export async function runSingleResultSSE(options: RunSingleResultSSEOptions): Promise<void> {
530
+ const { req, res, coreQueryFn } = options
531
+ let keepalive: IntervalHandle | null = null
532
+ try {
533
+ initSSE(res)
534
+ keepalive = startSSEKeepalive(res)
535
+ if (req.destroyed) return
536
+ const data = await coreQueryFn()
537
+ if (res.writableEnded || res.destroyed) return
538
+ sendSSEResult(res, data)
539
+ } catch (err) {
540
+ console.error('[progressive] single-result error:', err)
541
+ if (!res.writableEnded && !res.destroyed) {
542
+ sendSSEError(res, 'Internal server error')
543
+ }
544
+ } finally {
545
+ endSSE(res, keepalive)
546
+ }
547
+ }
548
+
549
+ function isStopResult(value: unknown): value is ProgressiveStopResult<unknown> {
550
+ return typeof value === 'object' && value !== null && (value as { stop?: unknown }).stop === true
551
+ }
552
+
553
+ export interface RunProgressiveOptions {
554
+ req: EventEmitterLike
555
+ res: SseWritable
556
+ ctx: unknown
557
+ prisma: unknown
558
+ variant: string
559
+ stages: string[]
560
+ stageRegistry: Record<string, ProgressiveStage>
561
+ }
562
+
563
+ export async function runProgressiveEndpoint(options: RunProgressiveOptions): Promise<void> {
564
+ const { req, res, ctx, prisma, variant, stages, stageRegistry } = options
565
+ let keepalive: IntervalHandle | null = null
566
+ const controller = new AbortController()
567
+ const onClose = () => controller.abort()
568
+ if (typeof req.on === 'function') req.on('close', onClose)
569
+
570
+ const accumulated: Record<string, unknown> = {}
571
+ const signal = controller.signal
572
+
573
+ try {
574
+ initSSE(res)
575
+ keepalive = startSSEKeepalive(res)
576
+ sendSSEProgress(res, 'start', 0, stages.length)
577
+
578
+ for (let i = 0; i < stages.length; i++) {
579
+ if (res.writableEnded || res.destroyed || signal.aborted) return
580
+ const stageName = stages[i]
581
+ const stage = stageRegistry[stageName]
582
+ if (!stage) throw new Error('Missing progressive stage: ' + stageName)
583
+
584
+ const result = await stage({ ctx, req, res, prisma, variant, accumulated, signal })
585
+ if (res.writableEnded || res.destroyed) return
586
+
587
+ if (isStopResult(result)) {
588
+ sendSSEResult(res, result.data)
589
+ return
590
+ }
591
+
592
+ const patches = Array.isArray(result) ? result : result ? [result] : []
593
+ for (const patch of patches) {
594
+ if (!patch || typeof patch !== 'object') continue
595
+ const p = patch as ProgressivePatch
596
+ if (typeof p.key !== 'string') continue
597
+ if (!('value' in p)) continue
598
+ const applied = setByPath(accumulated, p.key, p.value)
599
+ if (applied) sendSSEField(res, p.key, p.value)
600
+ }
601
+ sendSSEProgress(res, stageName, i + 1, stages.length)
602
+ }
603
+ if (res.writableEnded || res.destroyed) return
604
+ sendSSEResult(res, accumulated)
605
+ } catch (err) {
606
+ console.error('[progressive] stage error:', err)
607
+ if (!res.writableEnded && !res.destroyed) {
608
+ sendSSEError(res, 'Could not load progressive response')
609
+ }
610
+ } finally {
611
+ removeReqCloseListener(req, onClose)
612
+ endSSE(res, keepalive)
613
+ }
614
+ }
@@ -22,12 +22,14 @@ export type {
22
22
  ProgressiveStageContext,
23
23
  ProgressiveStage,
24
24
  ProgressiveVariantConfig,
25
+ ManualProgressiveVariantConfig,
26
+ AutoIncludeProgressiveVariantConfig,
25
27
  } from './routeConfig'
26
28
 
27
- export type OperationConfig<TShape = Record<string, any>> =
29
+ export type OperationConfig<TShape = Record<string, unknown>> =
28
30
  BaseOperationConfig<RequestHandler, TShape>
29
31
 
30
- export type ReadOperationConfig<TShape = Record<string, any>, TCtx = unknown> =
32
+ export type ReadOperationConfig<TShape = Record<string, unknown>, TCtx = unknown> =
31
33
  BaseOperationConfig<RequestHandler, TShape> & {
32
34
  progressive?: Record<string, ProgressiveVariantConfig>
33
35
  progressiveStages?: Record<string, ProgressiveStage<TCtx>>
@@ -45,6 +47,6 @@ type ReadOperationOverrides<TShape, TCtx> = {
45
47
  groupBy?: ReadOperationConfig<TShape, TCtx>
46
48
  }
47
49
 
48
- export type RouteConfig<TShape = Record<string, any>, TCtx = unknown> =
50
+ export type RouteConfig<TShape = Record<string, unknown>, TCtx = unknown> =
49
51
  BaseRouteConfig<RequestHandler, Request, TShape, TCtx> &
50
52
  ReadOperationOverrides<TShape, TCtx>
@@ -18,8 +18,8 @@ export type FastifyHookHandler = (
18
18
  reply: FastifyReply,
19
19
  ) => Promise<void> | void
20
20
 
21
- export type OperationConfig<TShape = Record<string, any>> =
21
+ export type OperationConfig<TShape = Record<string, unknown>> =
22
22
  BaseOperationConfig<FastifyHookHandler, TShape>
23
23
 
24
- export type RouteConfig<TShape = Record<string, any>, TCtx = unknown> =
24
+ export type RouteConfig<TShape = Record<string, unknown>, TCtx = unknown> =
25
25
  BaseRouteConfig<FastifyHookHandler, FastifyRequest, TShape, TCtx>
@@ -13,13 +13,13 @@ export type {
13
13
  OpenApiSecuritySchemeConfig,
14
14
  }
15
15
 
16
- export type HonoHookHandler<Env extends { Variables: any } = any> = (
16
+ export type HonoHookHandler<Env extends { Variables: Record<string, unknown> } = { Variables: Record<string, unknown> }> = (
17
17
  c: Context<Env>,
18
18
  next: Next,
19
19
  ) => Promise<Response | void> | Response | void
20
20
 
21
- export type OperationConfig<TShape = Record<string, any>> =
21
+ export type OperationConfig<TShape = Record<string, unknown>> =
22
22
  BaseOperationConfig<HonoHookHandler, TShape>
23
23
 
24
- export type RouteConfig<TShape = Record<string, any>, TCtx = unknown> =
24
+ export type RouteConfig<TShape = Record<string, unknown>, TCtx = unknown> =
25
25
  BaseRouteConfig<HonoHookHandler, Context, TShape, TCtx>