prisma-generator-express 1.52.0 → 1.53.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/README.md CHANGED
@@ -12,7 +12,7 @@ Running `npx prisma generate` produces:
12
12
  - Handler functions for all Prisma operations (findMany, create, update, delete, etc.)
13
13
  - Router generator with middleware support (before/after hooks per operation)
14
14
  - POST read endpoints for all read operations (for complex queries exceeding URL length limits)
15
- - Express-only progressive read streaming over Server-Sent Events (SSE), using manual stages or auto-include splitting for supported relation reads
15
+ - Express-only progressive read streaming over Server-Sent Events (SSE), using manual stages or auto-include splitting for supported relation reads, including deep `findMany` / `findManyPaginated` auto-include paths
16
16
  - Express-only standalone materialized view router for read-only access to registered PostgreSQL materialized views
17
17
  - OpenAPI 3.1 spec (JSON and YAML endpoints registered automatically per router)
18
18
  - Documentation helpers for contract view and Scalar UI (require manual mounting)
@@ -74,7 +74,7 @@ Some operations require newer versions:
74
74
 
75
75
  The Hono target v1 is tested on Node.js runtimes only. See [Cloudflare Workers and edge runtimes](#cloudflare-workers-and-edge-runtimes).
76
76
 
77
- Progressive Endpoint Composition over Server-Sent Events is currently supported by the Express target only. Express supports both manual staged streaming and auto-include streaming for supported relation reads, including single-record reads and limited `findMany` reads. Fastify and Hono continue to support normal JSON read and write routes.
77
+ Progressive Endpoint Composition over Server-Sent Events is currently supported by the Express target only. Express supports both manual staged streaming and auto-include streaming for supported relation reads, including single-record reads and deep `findMany` / `findManyPaginated` reads within the configured planner limits. Fastify and Hono continue to support normal JSON read and write routes.
78
78
 
79
79
  ### Database provider support
80
80
 
@@ -1562,7 +1562,7 @@ Progressive SSE has two modes:
1562
1562
 
1563
1563
  Manual mode is explicit staged data loading. You define stages yourself and each stage decides what query to run and which field path to patch.
1564
1564
 
1565
- Auto-include mode is generated relation loading. The router keeps the normal GET endpoint, runs the root query first, then loads supported included relations as separate follow-up queries. For single-record reads, relation paths are streamed as field patches. For `findMany`, direct root relations are loaded in batches and streamed as index-aligned relation batches.
1565
+ Auto-include mode is generated relation loading. The router keeps the normal GET endpoint, runs the root query first, then loads supported included relations as separate follow-up queries. For single-record reads, relation paths are streamed as field patches. For `findMany` and `findManyPaginated`, root rows are streamed first, direct root relation stages are streamed as index-aligned relation batches, and deeper nested relation stages are merged into the final result.
1566
1566
 
1567
1567
  ### Request format
1568
1568
 
@@ -1601,8 +1601,9 @@ Auto-include progressive SSE supports these Express GET read operations:
1601
1601
  - `findFirst`
1602
1602
  - `findFirstOrThrow`
1603
1603
  - `findMany`
1604
+ - `findManyPaginated`
1604
1605
 
1605
- `findMany` support is intentionally narrower than single-record support. It supports direct root relation loading only, with additional fallback cases listed in [Auto-include behavior and limits](#auto-include-behavior-and-limits).
1606
+ `findMany` and `findManyPaginated` support deep relation loading by flattening parent rows at each relation path, with fallback cases listed in [Auto-include behavior and limits](#auto-include-behavior-and-limits).
1606
1607
 
1607
1608
  If auto-include is configured on an unsupported operation, the router either falls back to single-result SSE or sends an SSE error depending on `fallback`.
1608
1609
 
@@ -1630,13 +1631,13 @@ Nested field event:
1630
1631
  { "type": "field", "key": "profile.appliedTo", "value": [] }
1631
1632
  ```
1632
1633
 
1633
- Root array event for `findMany` auto-include:
1634
+ Root array event for `findMany` / `findManyPaginated` auto-include:
1634
1635
 
1635
1636
  ```json
1636
1637
  { "type": "rootArray", "data": [{ "id": "user-1" }, { "id": "user-2" }] }
1637
1638
  ```
1638
1639
 
1639
- Relation batch event for `findMany` auto-include:
1640
+ Relation batch event for a direct root relation in `findMany` / `findManyPaginated` auto-include:
1640
1641
 
1641
1642
  ```json
1642
1643
  { "type": "relationBatch", "relationPath": "profile", "values": [{ "id": "profile-1" }, null] }
@@ -1656,7 +1657,9 @@ Error event:
1656
1657
 
1657
1658
  For single-record progressive responses, the final `result.data` is the accumulated object built from all applied patches, unless a manual stage returns a stop result.
1658
1659
 
1659
- For `findMany` auto-include responses, `rootArray.data` is the source of truth for root row identity and order. Each `relationBatch.values` array is index-aligned with `rootArray.data`, so `values[i]` belongs to `rootArray.data[i]`. The terminal `result.data` is the fully merged array.
1660
+ For `findMany` auto-include responses, `rootArray.data` is the source of truth for root row identity and order. Each depth-1 `relationBatch.values` array is index-aligned with `rootArray.data`, so `values[i]` belongs to `rootArray.data[i]`. Deeper nested stages emit progress events but do not emit relation batches, because their flattened parent indexes are not root-row indexes. The terminal `result.data` is the fully merged array.
1661
+
1662
+ For `findManyPaginated` auto-include responses, `pageMeta` is sent before `rootArray`. The terminal `result.data` has the normal paginated shape: `{ data, total, hasMore }`.
1660
1663
 
1661
1664
  ### Manual staged mode
1662
1665
 
@@ -1923,7 +1926,7 @@ On single-record reads, auto-include sends root scalar fields first, then sends
1923
1926
 
1924
1927
  The final `result` event contains the assembled object.
1925
1928
 
1926
- For `findMany`, auto-include sends the root rows first, then sends one relation batch event per supported relation stage:
1929
+ For `findMany`, auto-include sends the root rows first, then sends one relation batch event for each supported direct root relation stage. Nested relation stages emit progress and are included in the final result:
1927
1930
 
1928
1931
  ```ts
1929
1932
  const listConfig = {
@@ -1963,7 +1966,7 @@ const response = await fetch(`/user?${params}`, {
1963
1966
  })
1964
1967
  ```
1965
1968
 
1966
- Example `findMany` auto-include event sequence:
1969
+ Example shallow `findMany` auto-include event sequence:
1967
1970
 
1968
1971
  ```json
1969
1972
  { "type": "rootArray", "data": [{ "id": "user-1" }, { "id": "user-2" }] }
@@ -1977,6 +1980,64 @@ Example `findMany` auto-include event sequence:
1977
1980
  { "type": "result", "data": [{ "id": "user-1", "profile": { "id": "profile-1", "displayName": "Alice" } }, { "id": "user-2", "profile": null }] }
1978
1981
  ```
1979
1982
 
1983
+ Deep `findMany` and `findManyPaginated` requests use the same planner. Nested stages are loaded by flattening the parent rows at each path:
1984
+
1985
+ ```ts
1986
+ const params = encodeQueryParams({
1987
+ take: 20,
1988
+ include: {
1989
+ companies: {
1990
+ include: {
1991
+ users: {
1992
+ include: {
1993
+ profile: {
1994
+ select: {
1995
+ id: true,
1996
+ displayName: true,
1997
+ },
1998
+ },
1999
+ },
2000
+ },
2001
+ },
2002
+ },
2003
+ },
2004
+ })
2005
+
2006
+ const response = await fetch(`/organization/paginated?${params}`, {
2007
+ headers: {
2008
+ Accept: 'text/event-stream',
2009
+ 'x-api-variant': 'list',
2010
+ },
2011
+ })
2012
+ ```
2013
+
2014
+ Example deep event sequence:
2015
+
2016
+ ```json
2017
+ { "type": "pageMeta", "total": 120, "hasMore": true }
2018
+ ```
2019
+
2020
+ ```json
2021
+ { "type": "rootArray", "data": [{ "id": "org-1" }, { "id": "org-2" }] }
2022
+ ```
2023
+
2024
+ ```json
2025
+ { "type": "relationBatch", "relationPath": "companies", "values": [[{ "id": "company-1" }], []] }
2026
+ ```
2027
+
2028
+ ```json
2029
+ { "type": "progress", "stage": "companies.users", "completed": 2, "total": 3 }
2030
+ ```
2031
+
2032
+ ```json
2033
+ { "type": "progress", "stage": "companies.users.profile", "completed": 3, "total": 3 }
2034
+ ```
2035
+
2036
+ ```json
2037
+ { "type": "result", "data": { "data": [{ "id": "org-1", "companies": [{ "id": "company-1", "users": [{ "id": "user-1", "profile": { "id": "profile-1", "displayName": "Alice" } }] }] }, { "id": "org-2", "companies": [] }], "total": 120, "hasMore": true } }
2038
+ ```
2039
+
2040
+
1980
2041
  ### Auto-include behavior and limits
1981
2042
 
1982
2043
  Auto-include is designed for supported Prisma `include` and relation `select` trees on reads.
@@ -1988,22 +2049,28 @@ Supported root operations:
1988
2049
  - `findFirst`
1989
2050
  - `findFirstOrThrow`
1990
2051
  - `findMany`
2052
+ - `findManyPaginated`
1991
2053
 
1992
2054
  Supported single-record relation shapes:
1993
2055
 
1994
2056
  - direct to-one relation includes/selects
1995
2057
  - direct to-many relation includes/selects
1996
2058
  - to-many relation args such as `where`, `orderBy`, `take`, `skip`, `cursor`, and `distinct`
1997
- - nested to-one relation loading through to-one parents
2059
+ - nested relation loading through to-one parents
2060
+
2061
+ Single-record auto-include falls back when a nested stage crosses a to-many parent. Direct to-many loading is still supported, but nested loading under that array is not handled by the single-record progressive runtime.
1998
2062
 
1999
- Supported `findMany` relation shapes:
2063
+ Supported `findMany` and `findManyPaginated` relation shapes:
2000
2064
 
2001
- - direct root to-one relation includes/selects
2002
- - direct root to-many relation includes/selects
2065
+ - direct and nested to-one relation includes/selects
2066
+ - direct and nested to-many relation includes/selects
2003
2067
  - relation-level `where` and `orderBy`
2004
2068
  - single-column link fields only
2069
+ - nested depth up to the configured planner limit
2005
2070
 
2006
- `findMany` auto-include applies configured pagination limits to the root query before loading relation batches. If the client omits `take`, `pagination.defaultLimit` is applied when configured. If the client sends a large `take`, `pagination.maxLimit` is enforced before the root query runs.
2071
+ For `findMany` and `findManyPaginated`, each stage loads children with a batched query over the flattened parent rows at that stage's `parentPath`. Direct root stages can stream `relationBatch` events. Deeper stages are merged into the server-side result and visible in the terminal `result` event.
2072
+
2073
+ `findMany` and `findManyPaginated` auto-include apply configured pagination limits to the root query before loading relation batches. If the client omits `take`, `pagination.defaultLimit` is applied when configured. If the client sends a large `take`, `pagination.maxLimit` is enforced before the root query runs.
2007
2074
 
2008
2075
  Current MVP fallback cases include:
2009
2076
 
@@ -2013,13 +2080,12 @@ Current MVP fallback cases include:
2013
2080
  - `select` and `omit` at the same level
2014
2081
  - relation filters/order/cursor in the root query
2015
2082
  - relation filters/order/cursor inside staged relation queries when unsupported
2016
- - nested relation loading through a to-many parent
2017
2083
  - omitted required link fields needed to stitch parent and child records
2018
2084
  - planner limits for maximum depth or stage count
2019
- - `findMany` relation stages with composite link fields
2020
- - `findMany` nested relation stages deeper than direct root relations
2021
- - `findMany` to-many relation stages using `take`, `skip`, `cursor`, or `distinct`
2022
- - `findManyPaginated`, `createManyAndReturn`, and `updateManyAndReturn`
2085
+ - single-record nested relation loading through a to-many parent
2086
+ - `findMany` / `findManyPaginated` relation stages with composite link fields
2087
+ - `findMany` / `findManyPaginated` to-many relation stages using `take`, `skip`, `cursor`, or `distinct`
2088
+ - `createManyAndReturn` and `updateManyAndReturn`
2023
2089
 
2024
2090
  When fallback happens:
2025
2091
 
@@ -2028,7 +2094,7 @@ When fallback happens:
2028
2094
 
2029
2095
  If `fallback` is omitted, the default behavior is equivalent to `'singleResult'`.
2030
2096
 
2031
- `findMany` auto-include uses a batched relation loading strategy. It does not preserve Prisma's per-parent semantics for to-many `take`, `skip`, `cursor`, or `distinct`, so those cases fall back instead of returning silently different data.
2097
+ `findMany` and `findManyPaginated` auto-include use a batched relation loading strategy. They do not preserve Prisma's per-parent semantics for to-many `take`, `skip`, `cursor`, or `distinct`, so those cases fall back instead of returning silently different data.
2032
2098
 
2033
2099
  Auto-include does not require `resolveContext` or `progressiveStages`.
2034
2100
 
@@ -2118,6 +2184,8 @@ For React Query, include the variant and mode in the query key:
2118
2184
 
2119
2185
  Do not reuse the same query key as the JSON endpoint because the same URL can return different shapes depending on `x-api-variant`.
2120
2186
 
2187
+ For deep `findMany` / `findManyPaginated` auto-include, consume the final `result` event as the authoritative nested payload. Only direct root relation stages emit `relationBatch` events.
2188
+
2121
2189
  ### Runtime notes
2122
2190
 
2123
2191
  The SSE response sets:
@@ -237,7 +237,7 @@ export function ${routerFunctionName}<TCtx = unknown, TPrisma = any>(config: ${m
237
237
  const isAutoIncludeReadable =
238
238
  baseOp === 'findUnique' || baseOp === 'findUniqueOrThrow' ||
239
239
  baseOp === 'findFirst' || baseOp === 'findFirstOrThrow' ||
240
- baseOp === 'findMany'
240
+ baseOp === 'findMany' || baseOp === 'findManyPaginated'
241
241
 
242
242
  if (!isAutoIncludeReadable) {
243
243
  if (progressiveConfig.fallback === 'error') {
@@ -263,7 +263,7 @@ export function ${routerFunctionName}<TCtx = unknown, TPrisma = any>(config: ${m
263
263
  res,
264
264
  ctx,
265
265
  args,
266
- baseOp: baseOp as 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow' | 'findMany',
266
+ baseOp: baseOp as 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow' | 'findMany' | 'findManyPaginated',
267
267
  modelName: '${modelName}',
268
268
  delegateKey: '${delegateKey}',
269
269
  models: relationModels,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "prisma-generator-express",
3
3
  "description": "Prisma generator for Express, Fastify, and Hono CRUD APIs with OpenAPI documentation",
4
- "version": "1.52.0",
4
+ "version": "1.53.0",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "license": "MIT",
@@ -269,15 +269,9 @@ function walk(
269
269
  isPlainObject(relationArgs.omit)
270
270
 
271
271
  if (hasNestedProjection) {
272
- const stagesBeforeRecursion = ctx.stages.length
273
-
274
272
  const nested = walk(ctx, relation.type, relationPath, relationArgs, depth + 1)
275
273
  if (nested.unsupportedReason) return nested
276
274
 
277
- if (relation.isList && ctx.stages.length > stagesBeforeRecursion) {
278
- return { unsupportedReason: 'nested relation through to-many parent not supported in MVP' }
279
- }
280
-
281
275
  if (nested.projectionAfterStrip) {
282
276
  ctx.stages[stageIndex].stageArgs.select = nested.projectionAfterStrip
283
277
  }
@@ -9,12 +9,14 @@ import {
9
9
  sendSSEProgress,
10
10
  sendSSERootArray,
11
11
  sendSSERelationBatch,
12
+ sendSSEPageMeta,
12
13
  runSingleResultSSE,
13
14
  emitTerminalSSEError,
14
15
  setByPath,
15
16
  getDelegate,
16
17
  getExtendedClient,
17
18
  applyPaginationLimits,
19
+ countForPagination,
18
20
  mapError,
19
21
  type OperationContext,
20
22
  type PrismaDelegate,
@@ -39,6 +41,7 @@ export type AutoIncludeBaseOp =
39
41
  | 'findFirst'
40
42
  | 'findFirstOrThrow'
41
43
  | 'findMany'
44
+ | 'findManyPaginated'
42
45
 
43
46
  export type RunAutoIncludeOptions = {
44
47
  req: Request
@@ -54,6 +57,11 @@ export type RunAutoIncludeOptions = {
54
57
  signal?: AbortSignal
55
58
  }
56
59
 
60
+ type RowPair = {
61
+ internal: Record<string, unknown>
62
+ public: Record<string, unknown>
63
+ }
64
+
57
65
  function readPath(source: Record<string, unknown>, path: string): unknown {
58
66
  if (path === '') return source
59
67
  const parts = path.split('.')
@@ -196,9 +204,6 @@ function findManyUnsupportedReason(plan: AutoIncludePlan): string | null {
196
204
  if (rel.parentLinkFields.length !== 1 || rel.childLinkFields.length !== 1) {
197
205
  return 'auto-progressive fallback: composite link fields not supported for findMany batched auto-include'
198
206
  }
199
- if (stage.depth > 1) {
200
- return 'auto-progressive fallback: nested relations not supported for findMany batched auto-include'
201
- }
202
207
  if (rel.isList) {
203
208
  const sa = stage.stageArgs
204
209
  if (
@@ -214,6 +219,53 @@ function findManyUnsupportedReason(plan: AutoIncludePlan): string | null {
214
219
  return null
215
220
  }
216
221
 
222
+ function singleUnsupportedReason(plan: AutoIncludePlan): string | null {
223
+ const stagesByPath = new Map(plan.stages.map((s) => [s.relationPath, s]))
224
+ for (const stage of plan.stages) {
225
+ let parentPath = stage.parentPath
226
+ while (parentPath) {
227
+ const parentStage = stagesByPath.get(parentPath)
228
+ if (parentStage?.relationField.isList) {
229
+ return 'auto-progressive fallback: nested relation through to-many parent is not supported for single-result auto-include'
230
+ }
231
+ const dot = parentPath.lastIndexOf('.')
232
+ parentPath = dot === -1 ? '' : parentPath.slice(0, dot)
233
+ }
234
+ }
235
+ return null
236
+ }
237
+
238
+ function collectParentPairs(
239
+ rootPairs: RowPair[],
240
+ parentPath: string,
241
+ ): RowPair[] {
242
+ if (parentPath === '') return rootPairs
243
+ let pairs = rootPairs
244
+ for (const segment of parentPath.split('.')) {
245
+ const next: RowPair[] = []
246
+ for (const pair of pairs) {
247
+ const internalValue = pair.internal[segment]
248
+ const publicValue = pair.public[segment]
249
+ if (Array.isArray(internalValue) && Array.isArray(publicValue)) {
250
+ const length = Math.min(internalValue.length, publicValue.length)
251
+ for (let i = 0; i < length; i++) {
252
+ const internalItem = internalValue[i]
253
+ const publicItem = publicValue[i]
254
+ if (isPlainObject(internalItem) && isPlainObject(publicItem)) {
255
+ next.push({ internal: internalItem, public: publicItem })
256
+ }
257
+ }
258
+ continue
259
+ }
260
+ if (isPlainObject(internalValue) && isPlainObject(publicValue)) {
261
+ next.push({ internal: internalValue, public: publicValue })
262
+ }
263
+ }
264
+ pairs = next
265
+ }
266
+ return pairs
267
+ }
268
+
217
269
  async function runOneStageSingle(options: {
218
270
  extended: unknown
219
271
  models: Record<string, ModelRelationMap>
@@ -300,7 +352,7 @@ async function runAutoIncludeSingle(
300
352
 
301
353
  let rootResult: unknown
302
354
  try {
303
- rootResult = await rootDelegate[baseOp as Exclude<AutoIncludeBaseOp, 'findMany'>](plan.rootArgs)
355
+ rootResult = await rootDelegate[baseOp as Exclude<AutoIncludeBaseOp, 'findMany' | 'findManyPaginated'>](plan.rootArgs)
304
356
  } catch (err) {
305
357
  if (isClientGone()) return
306
358
  console.error('[auto-progressive] root query failed:', err)
@@ -470,16 +522,12 @@ async function runOneStageMany(options: {
470
522
  extended: unknown
471
523
  models: Record<string, ModelRelationMap>
472
524
  stage: AutoIncludeStage
473
- internalRows: Record<string, unknown>[]
474
- publicRows: Record<string, unknown>[]
525
+ parentPairs: RowPair[]
475
526
  internalFieldPaths: string[]
476
527
  res: Response
477
528
  isAborted: () => boolean
478
529
  }): Promise<void> {
479
- const {
480
- extended, models, stage, internalRows, publicRows,
481
- internalFieldPaths, res, isAborted,
482
- } = options
530
+ const { extended, models, stage, parentPairs, internalFieldPaths, res, isAborted } = options
483
531
 
484
532
  if (isAborted()) return
485
533
 
@@ -492,7 +540,15 @@ async function runOneStageMany(options: {
492
540
  throw new Error('Target model not in relation metadata: ' + rel.type)
493
541
  }
494
542
 
495
- const distinctValues = collectDistinctParentValues(internalRows, parentKey)
543
+ if (parentPairs.length === 0) {
544
+ if (stage.depth === 1) {
545
+ sendSSERelationBatch(res, stage.relationPath, [])
546
+ }
547
+ return
548
+ }
549
+
550
+ const internalParents = parentPairs.map((p) => p.internal)
551
+ const distinctValues = collectDistinctParentValues(internalParents, parentKey)
496
552
  const delegate: PrismaDelegate = getDelegate(extended, targetModel.delegateKey)
497
553
 
498
554
  const children: unknown[] = []
@@ -515,11 +571,11 @@ async function runOneStageMany(options: {
515
571
  : internalFieldPaths
516
572
 
517
573
  const grouped = groupRelatedRows(children, childKey)
518
- const publicValues: unknown[] = new Array(internalRows.length)
574
+ const publicValues: unknown[] = new Array(parentPairs.length)
519
575
 
520
- for (let i = 0; i < internalRows.length; i++) {
521
- const row = internalRows[i]
522
- const fkVal = row[parentKey]
576
+ for (let i = 0; i < parentPairs.length; i++) {
577
+ const pair = parentPairs[i]
578
+ const fkVal = pair.internal[parentKey]
523
579
  let internalVal: unknown
524
580
 
525
581
  if (fkVal === undefined || fkVal === null) {
@@ -535,13 +591,124 @@ async function runOneStageMany(options: {
535
591
 
536
592
  const publicVal = buildPublicForStage(internalVal, effectivePaths, stage.relationPath)
537
593
 
538
- internalRows[i][stage.relationName] = internalVal
539
- publicRows[i][stage.relationName] = publicVal
594
+ pair.internal[stage.relationName] = internalVal
595
+ pair.public[stage.relationName] = publicVal
540
596
  publicValues[i] = publicVal
541
597
  }
542
598
 
543
599
  if (isAborted()) return
544
- sendSSERelationBatch(res, stage.relationPath, publicValues)
600
+
601
+ if (stage.depth === 1) {
602
+ sendSSERelationBatch(res, stage.relationPath, publicValues)
603
+ }
604
+ }
605
+
606
+ function buildRootPairs(
607
+ rootRows: unknown[],
608
+ internalFieldPaths: string[],
609
+ ): { publicRows: Record<string, unknown>[]; rootPairs: RowPair[] } {
610
+ const n = rootRows.length
611
+ const publicRows: Record<string, unknown>[] = new Array(n)
612
+ const rootPairs: RowPair[] = new Array(n)
613
+ for (let i = 0; i < n; i++) {
614
+ const row = rootRows[i]
615
+ if (!isPlainObject(row)) {
616
+ const internalEmpty: Record<string, unknown> = {}
617
+ const publicEmpty: Record<string, unknown> = {}
618
+ publicRows[i] = publicEmpty
619
+ rootPairs[i] = { internal: internalEmpty, public: publicEmpty }
620
+ continue
621
+ }
622
+ const internalCopy: Record<string, unknown> = { ...row }
623
+ const publicCopy: Record<string, unknown> = { ...row }
624
+ stripInternalAtScope(publicCopy, internalFieldPaths, '')
625
+ publicRows[i] = publicCopy
626
+ rootPairs[i] = { internal: internalCopy, public: publicCopy }
627
+ }
628
+ return { publicRows, rootPairs }
629
+ }
630
+
631
+ async function processFindManyStages(args: {
632
+ extended: unknown
633
+ models: Record<string, ModelRelationMap>
634
+ plan: AutoIncludePlan
635
+ rootPairs: RowPair[]
636
+ res: Response
637
+ signal?: AbortSignal
638
+ }): Promise<string | null> {
639
+ const { extended, models, plan, rootPairs, res, signal } = args
640
+ const groups = groupStagesByDepth(plan.stages)
641
+ let completed = 0
642
+ let stageErrorMessage: string | null = null
643
+ const isAborted = () =>
644
+ stageErrorMessage !== null ||
645
+ signal?.aborted === true ||
646
+ res.writableEnded ||
647
+ res.destroyed
648
+
649
+ for (const group of groups) {
650
+ if (signal?.aborted === true || res.writableEnded || res.destroyed) return stageErrorMessage
651
+ if (stageErrorMessage) break
652
+
653
+ await runConcurrent(group, STAGE_CONCURRENCY, async (stage) => {
654
+ if (isAborted()) return
655
+ const parentPairs = collectParentPairs(rootPairs, stage.parentPath)
656
+ try {
657
+ await runOneStageMany({
658
+ extended,
659
+ models,
660
+ stage,
661
+ parentPairs,
662
+ internalFieldPaths: plan.internalFieldPaths,
663
+ res,
664
+ isAborted,
665
+ })
666
+ } catch (err) {
667
+ if (isAborted()) return
668
+ console.error('[auto-progressive] stage failed:', stage.relationPath, err)
669
+ stageErrorMessage = mapError(err).message
670
+ return
671
+ }
672
+ if (isAborted()) return
673
+ completed++
674
+ sendSSEProgress(res, stage.relationPath, completed, plan.stages.length)
675
+ })
676
+ }
677
+ return stageErrorMessage
678
+ }
679
+
680
+ async function runPaginatedRoot(
681
+ extended: unknown,
682
+ rootDelegate: PrismaDelegate,
683
+ delegateKey: string,
684
+ rootArgs: Record<string, unknown>,
685
+ distinctCountLimit: number | undefined,
686
+ ): Promise<{ data: unknown[]; count: number }> {
687
+ const txClient = extended as {
688
+ $transaction?: <T>(fn: (tx: unknown) => Promise<T>) => Promise<T>
689
+ }
690
+
691
+ if (typeof txClient.$transaction === 'function') {
692
+ try {
693
+ const result = await txClient.$transaction(async (tx: unknown) => {
694
+ const txDelegate = getDelegate(tx, delegateKey)
695
+ const d = await txDelegate.findMany(rootArgs)
696
+ const t = await countForPagination(txDelegate, rootArgs, undefined, undefined, distinctCountLimit)
697
+ return { d, t }
698
+ })
699
+ return { data: result.d as unknown[], count: result.t }
700
+ } catch (err) {
701
+ const e = err as { code?: string }
702
+ if (e?.code !== 'P2028') throw err
703
+ console.warn('[auto-progressive] Interactive transactions not available, paginated queries are non-atomic')
704
+ }
705
+ }
706
+
707
+ const [data, count] = await Promise.all([
708
+ rootDelegate.findMany(rootArgs),
709
+ countForPagination(rootDelegate, rootArgs, undefined, undefined, distinctCountLimit),
710
+ ])
711
+ return { data: data as unknown[], count }
545
712
  }
546
713
 
547
714
  async function runAutoIncludeMany(
@@ -582,78 +749,105 @@ async function runAutoIncludeMany(
582
749
  return
583
750
  }
584
751
 
585
- const internalRows: Record<string, unknown>[] = new Array(rootResult.length)
586
- const publicRows: Record<string, unknown>[] = new Array(rootResult.length)
752
+ const { publicRows, rootPairs } = buildRootPairs(rootResult, plan.internalFieldPaths)
587
753
 
588
- for (let i = 0; i < rootResult.length; i++) {
589
- const row = rootResult[i]
590
- if (!isPlainObject(row)) {
591
- internalRows[i] = {}
592
- publicRows[i] = {}
593
- continue
754
+ sendSSERootArray(res, publicRows)
755
+ sendSSEProgress(res, 'root', 0, plan.stages.length)
756
+
757
+ const stageError = await processFindManyStages({
758
+ extended, models, plan, rootPairs, res, signal,
759
+ })
760
+
761
+ if (isClientGone()) return
762
+
763
+ if (stageError) {
764
+ if (!res.writableEnded && !res.destroyed) {
765
+ sendSSEError(res, stageError)
594
766
  }
595
- const internalCopy: Record<string, unknown> = { ...row }
596
- const publicCopy: Record<string, unknown> = { ...row }
597
- stripInternalAtScope(publicCopy, plan.internalFieldPaths, '')
598
- internalRows[i] = internalCopy
599
- publicRows[i] = publicCopy
767
+ return
600
768
  }
601
769
 
602
- sendSSERootArray(res, publicRows)
603
- sendSSEProgress(res, 'root', 0, plan.stages.length)
770
+ if (res.writableEnded || res.destroyed) return
771
+ sendSSEResult(res, publicRows)
772
+ } catch (err) {
773
+ if (isClientGone()) return
774
+ console.error('[auto-progressive] many dispatch error:', err)
775
+ if (!res.writableEnded && !res.destroyed) {
776
+ sendSSEError(res, mapError(err).message)
777
+ }
778
+ } finally {
779
+ endSSE(res, keepalive)
780
+ }
781
+ }
604
782
 
605
- const groups = groupStagesByDepth(plan.stages)
606
- let completed = 0
607
- let stageErrorMessage: string | null = null
608
- const isAborted = () =>
609
- stageErrorMessage !== null ||
610
- signal?.aborted === true ||
611
- res.writableEnded ||
612
- res.destroyed
783
+ async function runAutoIncludePaginated(
784
+ options: RunAutoIncludeOptions,
785
+ plan: AutoIncludePlan,
786
+ ): Promise<void> {
787
+ const { res, ctx, delegateKey, models, signal } = options
613
788
 
614
- for (const group of groups) {
615
- if (isClientGone()) return
616
- if (stageErrorMessage) break
789
+ const isClientGone = () =>
790
+ signal?.aborted === true || res.writableEnded || res.destroyed
617
791
 
618
- await runConcurrent(group, STAGE_CONCURRENCY, async (stage) => {
619
- if (isAborted()) return
620
- try {
621
- await runOneStageMany({
622
- extended,
623
- models,
624
- stage,
625
- internalRows,
626
- publicRows,
627
- internalFieldPaths: plan.internalFieldPaths,
628
- res,
629
- isAborted,
630
- })
631
- } catch (err) {
632
- if (isAborted()) return
633
- console.error('[auto-progressive] stage failed:', stage.relationPath, err)
634
- stageErrorMessage = mapError(err).message
635
- return
636
- }
637
- if (isAborted()) return
638
- completed++
639
- sendSSEProgress(res, stage.relationPath, completed, plan.stages.length)
640
- })
792
+ let keepalive: IntervalHandle | null = null
793
+ try {
794
+ initSSE(res)
795
+ keepalive = startSSEKeepalive(res)
796
+ if (isClientGone()) return
797
+
798
+ const extended = await getExtendedClient(ctx)
799
+ if (isClientGone()) return
800
+
801
+ const rootDelegate = getDelegate(extended, delegateKey)
802
+ const rootArgs = applyPaginationLimits(plan.rootArgs, ctx.paginationConfig)
803
+ const distinctCountLimit = ctx.paginationConfig?.distinctCountLimit
804
+
805
+ let rootRows: unknown[]
806
+ let total: number
807
+ try {
808
+ const { data, count } = await runPaginatedRoot(
809
+ extended, rootDelegate, delegateKey, rootArgs, distinctCountLimit,
810
+ )
811
+ rootRows = data
812
+ total = count
813
+ } catch (err) {
814
+ if (isClientGone()) return
815
+ console.error('[auto-progressive] root findManyPaginated failed:', err)
816
+ sendSSEError(res, mapError(err).message)
817
+ return
641
818
  }
642
819
 
643
820
  if (isClientGone()) return
644
821
 
645
- if (stageErrorMessage) {
822
+ const skip = typeof rootArgs.skip === 'number' ? rootArgs.skip : 0
823
+ const takeRaw = typeof rootArgs.take === 'number' ? rootArgs.take : rootRows.length
824
+ const absTake = Math.abs(takeRaw)
825
+ const hasMore = rootRows.length >= absTake && skip + rootRows.length < total
826
+
827
+ const { publicRows, rootPairs } = buildRootPairs(rootRows, plan.internalFieldPaths)
828
+
829
+ sendSSEPageMeta(res, total, hasMore)
830
+ sendSSERootArray(res, publicRows)
831
+ sendSSEProgress(res, 'root', 0, plan.stages.length)
832
+
833
+ const stageError = await processFindManyStages({
834
+ extended, models, plan, rootPairs, res, signal,
835
+ })
836
+
837
+ if (isClientGone()) return
838
+
839
+ if (stageError) {
646
840
  if (!res.writableEnded && !res.destroyed) {
647
- sendSSEError(res, stageErrorMessage)
841
+ sendSSEError(res, stageError)
648
842
  }
649
843
  return
650
844
  }
651
845
 
652
846
  if (res.writableEnded || res.destroyed) return
653
- sendSSEResult(res, publicRows)
847
+ sendSSEResult(res, { data: publicRows, total, hasMore })
654
848
  } catch (err) {
655
849
  if (isClientGone()) return
656
- console.error('[auto-progressive] many dispatch error:', err)
850
+ console.error('[auto-progressive] paginated dispatch error:', err)
657
851
  if (!res.writableEnded && !res.destroyed) {
658
852
  sendSSEError(res, mapError(err).message)
659
853
  }
@@ -682,9 +876,12 @@ export async function runAutoIncludeProgressive(
682
876
  return handleAutoIncludeFallback(options, plan.unsupportedReason)
683
877
  }
684
878
 
685
- if (options.baseOp === 'findMany') {
879
+ if (options.baseOp === 'findMany' || options.baseOp === 'findManyPaginated') {
686
880
  const reason = findManyUnsupportedReason(plan)
687
881
  if (reason) return handleAutoIncludeFallback(options, reason)
882
+ } else {
883
+ const reason = singleUnsupportedReason(plan)
884
+ if (reason) return handleAutoIncludeFallback(options, reason)
688
885
  }
689
886
 
690
887
  if (plan.stages.length === 0) {
@@ -698,5 +895,8 @@ export async function runAutoIncludeProgressive(
698
895
  if (options.baseOp === 'findMany') {
699
896
  return runAutoIncludeMany(options, plan)
700
897
  }
898
+ if (options.baseOp === 'findManyPaginated') {
899
+ return runAutoIncludePaginated(options, plan)
900
+ }
701
901
  return runAutoIncludeSingle(options, plan)
702
902
  }
@@ -503,6 +503,14 @@ export function sendSSERelationBatch(
503
503
  return sendSSE(res, { type: 'relationBatch', relationPath, values })
504
504
  }
505
505
 
506
+ export function sendSSEPageMeta(
507
+ res: SseWritable,
508
+ total: number,
509
+ hasMore: boolean,
510
+ ): boolean {
511
+ return sendSSE(res, { type: 'pageMeta', total, hasMore })
512
+ }
513
+
506
514
  export function sendSSEError(res: SseWritable, message: string): boolean {
507
515
  if (res.writableEnded || res.destroyed) return false
508
516
  try {
@@ -253,7 +253,7 @@ export function ${routerFunctionName}<TCtx = unknown, TPrisma = any>(config: ${m
253
253
  const isAutoIncludeReadable =
254
254
  baseOp === 'findUnique' || baseOp === 'findUniqueOrThrow' ||
255
255
  baseOp === 'findFirst' || baseOp === 'findFirstOrThrow' ||
256
- baseOp === 'findMany'
256
+ baseOp === 'findMany' || baseOp === 'findManyPaginated'
257
257
 
258
258
  if (!isAutoIncludeReadable) {
259
259
  if (progressiveConfig.fallback === 'error') {
@@ -279,7 +279,7 @@ export function ${routerFunctionName}<TCtx = unknown, TPrisma = any>(config: ${m
279
279
  res,
280
280
  ctx,
281
281
  args,
282
- baseOp: baseOp as 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow' | 'findMany',
282
+ baseOp: baseOp as 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow' | 'findMany' | 'findManyPaginated',
283
283
  modelName: '${modelName}',
284
284
  delegateKey: '${delegateKey}',
285
285
  models: relationModels,