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,603 @@
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
+ 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 interface RunSingleResultSSEOptions {
513
+ req: EventEmitterLike
514
+ res: SseWritable
515
+ coreQueryFn: () => Promise<unknown>
516
+ }
517
+
518
+ export async function runSingleResultSSE(options: RunSingleResultSSEOptions): Promise<void> {
519
+ const { req, res, coreQueryFn } = options
520
+ let keepalive: IntervalHandle | null = null
521
+ try {
522
+ initSSE(res)
523
+ keepalive = startSSEKeepalive(res)
524
+ if (req.destroyed) return
525
+ const data = await coreQueryFn()
526
+ if (res.writableEnded || res.destroyed) return
527
+ sendSSEResult(res, data)
528
+ } catch (err) {
529
+ console.error('[progressive] single-result error:', err)
530
+ if (!res.writableEnded && !res.destroyed) {
531
+ sendSSEError(res, 'Internal server error')
532
+ }
533
+ } finally {
534
+ endSSE(res, keepalive)
535
+ }
536
+ }
537
+
538
+ function isStopResult(value: unknown): value is ProgressiveStopResult<unknown> {
539
+ return typeof value === 'object' && value !== null && (value as { stop?: unknown }).stop === true
540
+ }
541
+
542
+ export interface RunProgressiveOptions {
543
+ req: EventEmitterLike
544
+ res: SseWritable
545
+ ctx: unknown
546
+ prisma: unknown
547
+ variant: string
548
+ stages: string[]
549
+ stageRegistry: Record<string, ProgressiveStage>
550
+ }
551
+
552
+ export async function runProgressiveEndpoint(options: RunProgressiveOptions): Promise<void> {
553
+ const { req, res, ctx, prisma, variant, stages, stageRegistry } = options
554
+ let keepalive: IntervalHandle | null = null
555
+ const controller = new AbortController()
556
+ const onClose = () => controller.abort()
557
+ if (typeof req.on === 'function') req.on('close', onClose)
558
+
559
+ const accumulated: Record<string, unknown> = {}
560
+ const signal = controller.signal
561
+
562
+ try {
563
+ initSSE(res)
564
+ keepalive = startSSEKeepalive(res)
565
+ sendSSEProgress(res, 'start', 0, stages.length)
566
+
567
+ for (let i = 0; i < stages.length; i++) {
568
+ if (res.writableEnded || res.destroyed || signal.aborted) return
569
+ const stageName = stages[i]
570
+ const stage = stageRegistry[stageName]
571
+ if (!stage) throw new Error('Missing progressive stage: ' + stageName)
572
+
573
+ const result = await stage({ ctx, req, res, prisma, variant, accumulated, signal })
574
+ if (res.writableEnded || res.destroyed) return
575
+
576
+ if (isStopResult(result)) {
577
+ sendSSEResult(res, result.data)
578
+ return
579
+ }
580
+
581
+ const patches = Array.isArray(result) ? result : result ? [result] : []
582
+ for (const patch of patches) {
583
+ if (!patch || typeof patch !== 'object') continue
584
+ const p = patch as ProgressivePatch
585
+ if (typeof p.key !== 'string') continue
586
+ if (!('value' in p)) continue
587
+ const applied = setByPath(accumulated, p.key, p.value)
588
+ if (applied) sendSSEField(res, p.key, p.value)
589
+ }
590
+ sendSSEProgress(res, stageName, i + 1, stages.length)
591
+ }
592
+ if (res.writableEnded || res.destroyed) return
593
+ sendSSEResult(res, accumulated)
594
+ } catch (err) {
595
+ console.error('[progressive] stage error:', err)
596
+ if (!res.writableEnded && !res.destroyed) {
597
+ sendSSEError(res, 'Could not load progressive response')
598
+ }
599
+ } finally {
600
+ removeReqCloseListener(req, onClose)
601
+ endSSE(res, keepalive)
602
+ }
603
+ }
@@ -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>