prisma-generator-express 1.42.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.
- package/dist/generators/generateFastifyHandler.js +17 -4
- package/dist/generators/generateFastifyHandler.js.map +1 -1
- package/dist/generators/generateHonoHandler.js +4 -4
- package/dist/generators/generateOperationCore.d.ts +0 -1
- package/dist/generators/generateOperationCore.js +34 -546
- package/dist/generators/generateOperationCore.js.map +1 -1
- package/dist/generators/generateRelationMeta.d.ts +13 -0
- package/dist/generators/generateRelationMeta.js +106 -0
- package/dist/generators/generateRelationMeta.js.map +1 -0
- package/dist/generators/generateRouteConfigType.js +4 -4
- package/dist/generators/generateRouteConfigType.js.map +1 -1
- package/dist/generators/generateRouter.js +51 -13
- package/dist/generators/generateRouter.js.map +1 -1
- package/dist/generators/generateRouterFastify.js +127 -384
- package/dist/generators/generateRouterFastify.js.map +1 -1
- package/dist/generators/generateRouterHono.js +48 -36
- package/dist/generators/generateRouterHono.js.map +1 -1
- package/dist/generators/generateUnifiedHandler.js +24 -8
- package/dist/generators/generateUnifiedHandler.js.map +1 -1
- package/dist/index.js +21 -5
- package/dist/index.js.map +1 -1
- package/dist/utils/copyFiles.js +12 -0
- package/dist/utils/copyFiles.js.map +1 -1
- package/dist/utils/writeFileSafely.js +3 -0
- package/dist/utils/writeFileSafely.js.map +1 -1
- package/package.json +1 -1
- package/src/copy/autoIncludePlanner.ts +299 -0
- package/src/copy/autoIncludeRuntime.ts +307 -0
- package/src/copy/operationRuntime.ts +603 -0
- package/src/copy/routeConfig.express.ts +5 -3
- package/src/copy/routeConfig.fastify.ts +2 -2
- package/src/copy/routeConfig.hono.ts +3 -3
- package/src/copy/routeConfig.ts +20 -9
- package/src/generators/generateFastifyHandler.ts +17 -4
- package/src/generators/generateHonoHandler.ts +4 -4
- package/src/generators/generateOperationCore.ts +34 -546
- package/src/generators/generateRelationMeta.ts +154 -0
- package/src/generators/generateRouteConfigType.ts +5 -5
- package/src/generators/generateRouter.ts +51 -13
- package/src/generators/generateRouterFastify.ts +127 -384
- package/src/generators/generateRouterHono.ts +48 -36
- package/src/generators/generateUnifiedHandler.ts +24 -8
- package/src/index.ts +25 -7
- package/src/utils/copyFiles.ts +13 -0
- 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,
|
|
29
|
+
export type OperationConfig<TShape = Record<string, unknown>> =
|
|
28
30
|
BaseOperationConfig<RequestHandler, TShape>
|
|
29
31
|
|
|
30
|
-
export type ReadOperationConfig<TShape = Record<string,
|
|
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,
|
|
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,
|
|
21
|
+
export type OperationConfig<TShape = Record<string, unknown>> =
|
|
22
22
|
BaseOperationConfig<FastifyHookHandler, TShape>
|
|
23
23
|
|
|
24
|
-
export type RouteConfig<TShape = Record<string,
|
|
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:
|
|
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,
|
|
21
|
+
export type OperationConfig<TShape = Record<string, unknown>> =
|
|
22
22
|
BaseOperationConfig<HonoHookHandler, TShape>
|
|
23
23
|
|
|
24
|
-
export type RouteConfig<TShape = Record<string,
|
|
24
|
+
export type RouteConfig<TShape = Record<string, unknown>, TCtx = unknown> =
|
|
25
25
|
BaseRouteConfig<HonoHookHandler, Context, TShape, TCtx>
|