prisma-generator-express 1.52.0 → 1.54.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 streamed as locator-based nested relation batches. The terminal `result` event still contains the fully assembled payload.
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,18 +1631,33 @@ 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] }
1643
1644
  ```
1644
1645
 
1646
+ Nested relation batch event for a depth-2-or-deeper relation in `findMany` / `findManyPaginated` auto-include:
1647
+
1648
+ ```json
1649
+ {
1650
+ "type": "nestedRelationBatch",
1651
+ "relationPath": "companies.users",
1652
+ "depth": 2,
1653
+ "attachments": [
1654
+ { "locator": [0, "companies", 0], "value": [{ "id": "user-1" }] }
1655
+ ]
1656
+ }
1657
+ ```
1658
+
1659
+ Each `attachments[].locator` is walked from `rootArray.data` to the parent object. The leaf field to assign is the last segment of `relationPath`. For example, `relationPath: "companies.users"` and `locator: [0, "companies", 0]` means `rootArray.data[0].companies[0].users = value`.
1660
+
1645
1661
  Final result event:
1646
1662
 
1647
1663
  ```json
@@ -1656,7 +1672,9 @@ Error event:
1656
1672
 
1657
1673
  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
1674
 
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.
1675
+ 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]`. Each depth-2-or-deeper `nestedRelationBatch.attachments` array carries locator/value pairs that can be applied to the accumulated root rows immediately. The terminal `result.data` is the fully merged array and can be used as a final reconcile.
1676
+
1677
+ For `findManyPaginated` auto-include responses, `pageMeta` is sent before `rootArray`. The terminal `result.data` has the normal paginated shape: `{ data, total, hasMore }`.
1660
1678
 
1661
1679
  ### Manual staged mode
1662
1680
 
@@ -1923,7 +1941,7 @@ On single-record reads, auto-include sends root scalar fields first, then sends
1923
1941
 
1924
1942
  The final `result` event contains the assembled object.
1925
1943
 
1926
- For `findMany`, auto-include sends the root rows first, then sends one relation batch event per supported relation stage:
1944
+ For `findMany`, auto-include sends the root rows first, then sends one relation batch event for each supported direct root relation stage. Depth-2-or-deeper stages send `nestedRelationBatch` events with locators pointing to the parent object inside the accumulated root rows:
1927
1945
 
1928
1946
  ```ts
1929
1947
  const listConfig = {
@@ -1963,7 +1981,7 @@ const response = await fetch(`/user?${params}`, {
1963
1981
  })
1964
1982
  ```
1965
1983
 
1966
- Example `findMany` auto-include event sequence:
1984
+ Example shallow `findMany` auto-include event sequence:
1967
1985
 
1968
1986
  ```json
1969
1987
  { "type": "rootArray", "data": [{ "id": "user-1" }, { "id": "user-2" }] }
@@ -1977,6 +1995,78 @@ Example `findMany` auto-include event sequence:
1977
1995
  { "type": "result", "data": [{ "id": "user-1", "profile": { "id": "profile-1", "displayName": "Alice" } }, { "id": "user-2", "profile": null }] }
1978
1996
  ```
1979
1997
 
1998
+ Deep `findMany` and `findManyPaginated` requests use the same planner. Nested stages are loaded by flattening the parent rows at each path, then emitted as locator-based attachment batches:
1999
+
2000
+ ```ts
2001
+ const params = encodeQueryParams({
2002
+ take: 20,
2003
+ include: {
2004
+ companies: {
2005
+ include: {
2006
+ users: {
2007
+ include: {
2008
+ profile: {
2009
+ select: {
2010
+ id: true,
2011
+ displayName: true,
2012
+ },
2013
+ },
2014
+ },
2015
+ },
2016
+ },
2017
+ },
2018
+ },
2019
+ })
2020
+
2021
+ const response = await fetch(`/organization/paginated?${params}`, {
2022
+ headers: {
2023
+ Accept: 'text/event-stream',
2024
+ 'x-api-variant': 'list',
2025
+ },
2026
+ })
2027
+ ```
2028
+
2029
+ Example deep event sequence:
2030
+
2031
+ ```json
2032
+ { "type": "pageMeta", "total": 120, "hasMore": true }
2033
+ ```
2034
+
2035
+ ```json
2036
+ { "type": "rootArray", "data": [{ "id": "org-1" }, { "id": "org-2" }] }
2037
+ ```
2038
+
2039
+ ```json
2040
+ { "type": "relationBatch", "relationPath": "companies", "values": [[{ "id": "company-1" }], []] }
2041
+ ```
2042
+
2043
+ ```json
2044
+ {
2045
+ "type": "nestedRelationBatch",
2046
+ "relationPath": "companies.users",
2047
+ "depth": 2,
2048
+ "attachments": [
2049
+ { "locator": [0, "companies", 0], "value": [{ "id": "user-1" }] }
2050
+ ]
2051
+ }
2052
+ ```
2053
+
2054
+ ```json
2055
+ {
2056
+ "type": "nestedRelationBatch",
2057
+ "relationPath": "companies.users.profile",
2058
+ "depth": 3,
2059
+ "attachments": [
2060
+ { "locator": [0, "companies", 0, "users", 0], "value": { "id": "profile-1", "displayName": "Alice" } }
2061
+ ]
2062
+ }
2063
+ ```
2064
+
2065
+ ```json
2066
+ { "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 } }
2067
+ ```
2068
+
2069
+
1980
2070
  ### Auto-include behavior and limits
1981
2071
 
1982
2072
  Auto-include is designed for supported Prisma `include` and relation `select` trees on reads.
@@ -1988,22 +2078,28 @@ Supported root operations:
1988
2078
  - `findFirst`
1989
2079
  - `findFirstOrThrow`
1990
2080
  - `findMany`
2081
+ - `findManyPaginated`
1991
2082
 
1992
2083
  Supported single-record relation shapes:
1993
2084
 
1994
2085
  - direct to-one relation includes/selects
1995
2086
  - direct to-many relation includes/selects
1996
2087
  - to-many relation args such as `where`, `orderBy`, `take`, `skip`, `cursor`, and `distinct`
1997
- - nested to-one relation loading through to-one parents
2088
+ - nested relation loading through to-one parents
2089
+
2090
+ 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
2091
 
1999
- Supported `findMany` relation shapes:
2092
+ Supported `findMany` and `findManyPaginated` relation shapes:
2000
2093
 
2001
- - direct root to-one relation includes/selects
2002
- - direct root to-many relation includes/selects
2094
+ - direct and nested to-one relation includes/selects
2095
+ - direct and nested to-many relation includes/selects
2003
2096
  - relation-level `where` and `orderBy`
2004
2097
  - single-column link fields only
2098
+ - nested depth up to the configured planner limit
2005
2099
 
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.
2100
+ 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 stream `relationBatch` events. Depth-2-or-deeper stages stream `nestedRelationBatch` events with locator/value attachments, then also appear in the terminal `result` event.
2101
+
2102
+ `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
2103
 
2008
2104
  Current MVP fallback cases include:
2009
2105
 
@@ -2013,13 +2109,12 @@ Current MVP fallback cases include:
2013
2109
  - `select` and `omit` at the same level
2014
2110
  - relation filters/order/cursor in the root query
2015
2111
  - relation filters/order/cursor inside staged relation queries when unsupported
2016
- - nested relation loading through a to-many parent
2017
2112
  - omitted required link fields needed to stitch parent and child records
2018
2113
  - 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`
2114
+ - single-record nested relation loading through a to-many parent
2115
+ - `findMany` / `findManyPaginated` relation stages with composite link fields
2116
+ - `findMany` / `findManyPaginated` to-many relation stages using `take`, `skip`, `cursor`, or `distinct`
2117
+ - `createManyAndReturn` and `updateManyAndReturn`
2023
2118
 
2024
2119
  When fallback happens:
2025
2120
 
@@ -2028,7 +2123,7 @@ When fallback happens:
2028
2123
 
2029
2124
  If `fallback` is omitted, the default behavior is equivalent to `'singleResult'`.
2030
2125
 
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.
2126
+ `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
2127
 
2033
2128
  Auto-include does not require `resolveContext` or `progressiveStages`.
2034
2129
 
@@ -2071,6 +2166,20 @@ let rows: Array<Record<string, unknown>> = []
2071
2166
  let fields: Record<string, unknown> = {}
2072
2167
  let data: unknown = undefined
2073
2168
 
2169
+ const lastSegment = (path: string) => {
2170
+ const parts = path.split('.')
2171
+ return parts[parts.length - 1] ?? path
2172
+ }
2173
+
2174
+ const walk = (source: Array<Record<string, unknown>>, locator: Array<number | string>) => {
2175
+ let cursor: unknown = source[locator[0] as number]
2176
+ for (let i = 1; i < locator.length; i++) {
2177
+ if (cursor == null) return null
2178
+ cursor = (cursor as Record<string | number, unknown>)[locator[i]]
2179
+ }
2180
+ return cursor
2181
+ }
2182
+
2074
2183
  while (true) {
2075
2184
  const { value, done } = await reader.read()
2076
2185
  if (done) break
@@ -2097,12 +2206,24 @@ while (true) {
2097
2206
  }
2098
2207
 
2099
2208
  if (event.type === 'relationBatch') {
2209
+ const field = lastSegment(event.relationPath)
2100
2210
  rows = rows.map((row, index) => ({
2101
2211
  ...row,
2102
- [event.relationPath]: event.values[index],
2212
+ [field]: event.values[index],
2103
2213
  }))
2104
2214
  }
2105
2215
 
2216
+ if (event.type === 'nestedRelationBatch') {
2217
+ const field = lastSegment(event.relationPath)
2218
+ for (const attachment of event.attachments) {
2219
+ const parent = walk(rows, attachment.locator)
2220
+ if (parent && typeof parent === 'object' && !Array.isArray(parent)) {
2221
+ const record = parent as Record<string, unknown>
2222
+ record[field] = attachment.value
2223
+ }
2224
+ }
2225
+ }
2226
+
2106
2227
  if (event.type === 'result') {
2107
2228
  data = event.data
2108
2229
  }
@@ -2118,6 +2239,8 @@ For React Query, include the variant and mode in the query key:
2118
2239
 
2119
2240
  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
2241
 
2242
+ For deep `findMany` / `findManyPaginated` auto-include, apply `nestedRelationBatch` events for progressive rendering and still treat the final `result` event as the authoritative nested payload for reconciliation. Direct root relation stages emit `relationBatch`; depth-2-or-deeper relation stages emit `nestedRelationBatch`.
2243
+
2121
2244
  ### Runtime notes
2122
2245
 
2123
2246
  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.54.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,15 @@ import {
9
9
  sendSSEProgress,
10
10
  sendSSERootArray,
11
11
  sendSSERelationBatch,
12
+ sendSSENestedRelationBatch,
13
+ sendSSEPageMeta,
12
14
  runSingleResultSSE,
13
15
  emitTerminalSSEError,
14
16
  setByPath,
15
17
  getDelegate,
16
18
  getExtendedClient,
17
19
  applyPaginationLimits,
20
+ countForPagination,
18
21
  mapError,
19
22
  type OperationContext,
20
23
  type PrismaDelegate,
@@ -39,6 +42,7 @@ export type AutoIncludeBaseOp =
39
42
  | 'findFirst'
40
43
  | 'findFirstOrThrow'
41
44
  | 'findMany'
45
+ | 'findManyPaginated'
42
46
 
43
47
  export type RunAutoIncludeOptions = {
44
48
  req: Request
@@ -54,6 +58,15 @@ export type RunAutoIncludeOptions = {
54
58
  signal?: AbortSignal
55
59
  }
56
60
 
61
+ type RowPair = {
62
+ internal: Record<string, unknown>
63
+ public: Record<string, unknown>
64
+ }
65
+
66
+ type ParentEntry = RowPair & {
67
+ locator: Array<number | string>
68
+ }
69
+
57
70
  function readPath(source: Record<string, unknown>, path: string): unknown {
58
71
  if (path === '') return source
59
72
  const parts = path.split('.')
@@ -196,9 +209,6 @@ function findManyUnsupportedReason(plan: AutoIncludePlan): string | null {
196
209
  if (rel.parentLinkFields.length !== 1 || rel.childLinkFields.length !== 1) {
197
210
  return 'auto-progressive fallback: composite link fields not supported for findMany batched auto-include'
198
211
  }
199
- if (stage.depth > 1) {
200
- return 'auto-progressive fallback: nested relations not supported for findMany batched auto-include'
201
- }
202
212
  if (rel.isList) {
203
213
  const sa = stage.stageArgs
204
214
  if (
@@ -214,6 +224,67 @@ function findManyUnsupportedReason(plan: AutoIncludePlan): string | null {
214
224
  return null
215
225
  }
216
226
 
227
+ function singleUnsupportedReason(plan: AutoIncludePlan): string | null {
228
+ const stagesByPath = new Map(plan.stages.map((s) => [s.relationPath, s]))
229
+ for (const stage of plan.stages) {
230
+ let parentPath = stage.parentPath
231
+ while (parentPath) {
232
+ const parentStage = stagesByPath.get(parentPath)
233
+ if (parentStage?.relationField.isList) {
234
+ return 'auto-progressive fallback: nested relation through to-many parent is not supported for single-result auto-include'
235
+ }
236
+ const dot = parentPath.lastIndexOf('.')
237
+ parentPath = dot === -1 ? '' : parentPath.slice(0, dot)
238
+ }
239
+ }
240
+ return null
241
+ }
242
+
243
+ function collectParentEntries(
244
+ rootPairs: RowPair[],
245
+ parentPath: string,
246
+ ): ParentEntry[] {
247
+ const initial: ParentEntry[] = rootPairs.map((p, i) => ({
248
+ internal: p.internal,
249
+ public: p.public,
250
+ locator: [i],
251
+ }))
252
+ if (parentPath === '') return initial
253
+
254
+ let entries = initial
255
+ for (const segment of parentPath.split('.')) {
256
+ const next: ParentEntry[] = []
257
+ for (const entry of entries) {
258
+ const internalValue = entry.internal[segment]
259
+ const publicValue = entry.public[segment]
260
+ if (Array.isArray(internalValue) && Array.isArray(publicValue)) {
261
+ const length = Math.min(internalValue.length, publicValue.length)
262
+ for (let i = 0; i < length; i++) {
263
+ const internalItem = internalValue[i]
264
+ const publicItem = publicValue[i]
265
+ if (isPlainObject(internalItem) && isPlainObject(publicItem)) {
266
+ next.push({
267
+ internal: internalItem,
268
+ public: publicItem,
269
+ locator: [...entry.locator, segment, i],
270
+ })
271
+ }
272
+ }
273
+ continue
274
+ }
275
+ if (isPlainObject(internalValue) && isPlainObject(publicValue)) {
276
+ next.push({
277
+ internal: internalValue,
278
+ public: publicValue,
279
+ locator: [...entry.locator, segment],
280
+ })
281
+ }
282
+ }
283
+ entries = next
284
+ }
285
+ return entries
286
+ }
287
+
217
288
  async function runOneStageSingle(options: {
218
289
  extended: unknown
219
290
  models: Record<string, ModelRelationMap>
@@ -300,7 +371,7 @@ async function runAutoIncludeSingle(
300
371
 
301
372
  let rootResult: unknown
302
373
  try {
303
- rootResult = await rootDelegate[baseOp as Exclude<AutoIncludeBaseOp, 'findMany'>](plan.rootArgs)
374
+ rootResult = await rootDelegate[baseOp as Exclude<AutoIncludeBaseOp, 'findMany' | 'findManyPaginated'>](plan.rootArgs)
304
375
  } catch (err) {
305
376
  if (isClientGone()) return
306
377
  console.error('[auto-progressive] root query failed:', err)
@@ -470,16 +541,12 @@ async function runOneStageMany(options: {
470
541
  extended: unknown
471
542
  models: Record<string, ModelRelationMap>
472
543
  stage: AutoIncludeStage
473
- internalRows: Record<string, unknown>[]
474
- publicRows: Record<string, unknown>[]
544
+ parentEntries: ParentEntry[]
475
545
  internalFieldPaths: string[]
476
546
  res: Response
477
547
  isAborted: () => boolean
478
548
  }): Promise<void> {
479
- const {
480
- extended, models, stage, internalRows, publicRows,
481
- internalFieldPaths, res, isAborted,
482
- } = options
549
+ const { extended, models, stage, parentEntries, internalFieldPaths, res, isAborted } = options
483
550
 
484
551
  if (isAborted()) return
485
552
 
@@ -492,7 +559,17 @@ async function runOneStageMany(options: {
492
559
  throw new Error('Target model not in relation metadata: ' + rel.type)
493
560
  }
494
561
 
495
- const distinctValues = collectDistinctParentValues(internalRows, parentKey)
562
+ if (parentEntries.length === 0) {
563
+ if (stage.depth === 1) {
564
+ sendSSERelationBatch(res, stage.relationPath, [])
565
+ } else {
566
+ sendSSENestedRelationBatch(res, stage.relationPath, stage.depth, [])
567
+ }
568
+ return
569
+ }
570
+
571
+ const internalParents = parentEntries.map((p) => p.internal)
572
+ const distinctValues = collectDistinctParentValues(internalParents, parentKey)
496
573
  const delegate: PrismaDelegate = getDelegate(extended, targetModel.delegateKey)
497
574
 
498
575
  const children: unknown[] = []
@@ -515,11 +592,11 @@ async function runOneStageMany(options: {
515
592
  : internalFieldPaths
516
593
 
517
594
  const grouped = groupRelatedRows(children, childKey)
518
- const publicValues: unknown[] = new Array(internalRows.length)
595
+ const publicValues: unknown[] = new Array(parentEntries.length)
519
596
 
520
- for (let i = 0; i < internalRows.length; i++) {
521
- const row = internalRows[i]
522
- const fkVal = row[parentKey]
597
+ for (let i = 0; i < parentEntries.length; i++) {
598
+ const entry = parentEntries[i]
599
+ const fkVal = entry.internal[parentKey]
523
600
  let internalVal: unknown
524
601
 
525
602
  if (fkVal === undefined || fkVal === null) {
@@ -535,13 +612,131 @@ async function runOneStageMany(options: {
535
612
 
536
613
  const publicVal = buildPublicForStage(internalVal, effectivePaths, stage.relationPath)
537
614
 
538
- internalRows[i][stage.relationName] = internalVal
539
- publicRows[i][stage.relationName] = publicVal
615
+ entry.internal[stage.relationName] = internalVal
616
+ entry.public[stage.relationName] = publicVal
540
617
  publicValues[i] = publicVal
541
618
  }
542
619
 
543
620
  if (isAborted()) return
544
- sendSSERelationBatch(res, stage.relationPath, publicValues)
621
+
622
+ if (stage.depth === 1) {
623
+ sendSSERelationBatch(res, stage.relationPath, publicValues)
624
+ return
625
+ }
626
+
627
+ const attachments = parentEntries.map((entry, i) => ({
628
+ locator: entry.locator,
629
+ value: publicValues[i],
630
+ }))
631
+ sendSSENestedRelationBatch(res, stage.relationPath, stage.depth, attachments)
632
+ }
633
+
634
+ function buildRootPairs(
635
+ rootRows: unknown[],
636
+ internalFieldPaths: string[],
637
+ ): { publicRows: Record<string, unknown>[]; rootPairs: RowPair[] } {
638
+ const n = rootRows.length
639
+ const publicRows: Record<string, unknown>[] = new Array(n)
640
+ const rootPairs: RowPair[] = new Array(n)
641
+ for (let i = 0; i < n; i++) {
642
+ const row = rootRows[i]
643
+ if (!isPlainObject(row)) {
644
+ const internalEmpty: Record<string, unknown> = {}
645
+ const publicEmpty: Record<string, unknown> = {}
646
+ publicRows[i] = publicEmpty
647
+ rootPairs[i] = { internal: internalEmpty, public: publicEmpty }
648
+ continue
649
+ }
650
+ const internalCopy: Record<string, unknown> = { ...row }
651
+ const publicCopy: Record<string, unknown> = { ...row }
652
+ stripInternalAtScope(publicCopy, internalFieldPaths, '')
653
+ publicRows[i] = publicCopy
654
+ rootPairs[i] = { internal: internalCopy, public: publicCopy }
655
+ }
656
+ return { publicRows, rootPairs }
657
+ }
658
+
659
+ async function processFindManyStages(args: {
660
+ extended: unknown
661
+ models: Record<string, ModelRelationMap>
662
+ plan: AutoIncludePlan
663
+ rootPairs: RowPair[]
664
+ res: Response
665
+ signal?: AbortSignal
666
+ }): Promise<string | null> {
667
+ const { extended, models, plan, rootPairs, res, signal } = args
668
+ const groups = groupStagesByDepth(plan.stages)
669
+ let completed = 0
670
+ let stageErrorMessage: string | null = null
671
+ const isAborted = () =>
672
+ stageErrorMessage !== null ||
673
+ signal?.aborted === true ||
674
+ res.writableEnded ||
675
+ res.destroyed
676
+
677
+ for (const group of groups) {
678
+ if (signal?.aborted === true || res.writableEnded || res.destroyed) return stageErrorMessage
679
+ if (stageErrorMessage) break
680
+
681
+ await runConcurrent(group, STAGE_CONCURRENCY, async (stage) => {
682
+ if (isAborted()) return
683
+ const parentEntries = collectParentEntries(rootPairs, stage.parentPath)
684
+ try {
685
+ await runOneStageMany({
686
+ extended,
687
+ models,
688
+ stage,
689
+ parentEntries,
690
+ internalFieldPaths: plan.internalFieldPaths,
691
+ res,
692
+ isAborted,
693
+ })
694
+ } catch (err) {
695
+ if (isAborted()) return
696
+ console.error('[auto-progressive] stage failed:', stage.relationPath, err)
697
+ stageErrorMessage = mapError(err).message
698
+ return
699
+ }
700
+ if (isAborted()) return
701
+ completed++
702
+ sendSSEProgress(res, stage.relationPath, completed, plan.stages.length)
703
+ })
704
+ }
705
+ return stageErrorMessage
706
+ }
707
+
708
+ async function runPaginatedRoot(
709
+ extended: unknown,
710
+ rootDelegate: PrismaDelegate,
711
+ delegateKey: string,
712
+ rootArgs: Record<string, unknown>,
713
+ distinctCountLimit: number | undefined,
714
+ ): Promise<{ data: unknown[]; count: number }> {
715
+ const txClient = extended as {
716
+ $transaction?: <T>(fn: (tx: unknown) => Promise<T>) => Promise<T>
717
+ }
718
+
719
+ if (typeof txClient.$transaction === 'function') {
720
+ try {
721
+ const result = await txClient.$transaction(async (tx: unknown) => {
722
+ const txDelegate = getDelegate(tx, delegateKey)
723
+ const d = await txDelegate.findMany(rootArgs)
724
+ const t = await countForPagination(txDelegate, rootArgs, undefined, undefined, distinctCountLimit)
725
+ return { d, t }
726
+ })
727
+ return { data: result.d as unknown[], count: result.t }
728
+ } catch (err) {
729
+ const e = err as { code?: string }
730
+ if (e?.code !== 'P2028') throw err
731
+ console.warn('[auto-progressive] Interactive transactions not available, paginated queries are non-atomic')
732
+ }
733
+ }
734
+
735
+ const [data, count] = await Promise.all([
736
+ rootDelegate.findMany(rootArgs),
737
+ countForPagination(rootDelegate, rootArgs, undefined, undefined, distinctCountLimit),
738
+ ])
739
+ return { data: data as unknown[], count }
545
740
  }
546
741
 
547
742
  async function runAutoIncludeMany(
@@ -582,78 +777,105 @@ async function runAutoIncludeMany(
582
777
  return
583
778
  }
584
779
 
585
- const internalRows: Record<string, unknown>[] = new Array(rootResult.length)
586
- const publicRows: Record<string, unknown>[] = new Array(rootResult.length)
780
+ const { publicRows, rootPairs } = buildRootPairs(rootResult, plan.internalFieldPaths)
587
781
 
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
782
+ sendSSERootArray(res, publicRows)
783
+ sendSSEProgress(res, 'root', 0, plan.stages.length)
784
+
785
+ const stageError = await processFindManyStages({
786
+ extended, models, plan, rootPairs, res, signal,
787
+ })
788
+
789
+ if (isClientGone()) return
790
+
791
+ if (stageError) {
792
+ if (!res.writableEnded && !res.destroyed) {
793
+ sendSSEError(res, stageError)
594
794
  }
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
795
+ return
600
796
  }
601
797
 
602
- sendSSERootArray(res, publicRows)
603
- sendSSEProgress(res, 'root', 0, plan.stages.length)
798
+ if (res.writableEnded || res.destroyed) return
799
+ sendSSEResult(res, publicRows)
800
+ } catch (err) {
801
+ if (isClientGone()) return
802
+ console.error('[auto-progressive] many dispatch error:', err)
803
+ if (!res.writableEnded && !res.destroyed) {
804
+ sendSSEError(res, mapError(err).message)
805
+ }
806
+ } finally {
807
+ endSSE(res, keepalive)
808
+ }
809
+ }
604
810
 
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
811
+ async function runAutoIncludePaginated(
812
+ options: RunAutoIncludeOptions,
813
+ plan: AutoIncludePlan,
814
+ ): Promise<void> {
815
+ const { res, ctx, delegateKey, models, signal } = options
613
816
 
614
- for (const group of groups) {
615
- if (isClientGone()) return
616
- if (stageErrorMessage) break
817
+ const isClientGone = () =>
818
+ signal?.aborted === true || res.writableEnded || res.destroyed
617
819
 
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
- })
820
+ let keepalive: IntervalHandle | null = null
821
+ try {
822
+ initSSE(res)
823
+ keepalive = startSSEKeepalive(res)
824
+ if (isClientGone()) return
825
+
826
+ const extended = await getExtendedClient(ctx)
827
+ if (isClientGone()) return
828
+
829
+ const rootDelegate = getDelegate(extended, delegateKey)
830
+ const rootArgs = applyPaginationLimits(plan.rootArgs, ctx.paginationConfig)
831
+ const distinctCountLimit = ctx.paginationConfig?.distinctCountLimit
832
+
833
+ let rootRows: unknown[]
834
+ let total: number
835
+ try {
836
+ const { data, count } = await runPaginatedRoot(
837
+ extended, rootDelegate, delegateKey, rootArgs, distinctCountLimit,
838
+ )
839
+ rootRows = data
840
+ total = count
841
+ } catch (err) {
842
+ if (isClientGone()) return
843
+ console.error('[auto-progressive] root findManyPaginated failed:', err)
844
+ sendSSEError(res, mapError(err).message)
845
+ return
641
846
  }
642
847
 
643
848
  if (isClientGone()) return
644
849
 
645
- if (stageErrorMessage) {
850
+ const skip = typeof rootArgs.skip === 'number' ? rootArgs.skip : 0
851
+ const takeRaw = typeof rootArgs.take === 'number' ? rootArgs.take : rootRows.length
852
+ const absTake = Math.abs(takeRaw)
853
+ const hasMore = rootRows.length >= absTake && skip + rootRows.length < total
854
+
855
+ const { publicRows, rootPairs } = buildRootPairs(rootRows, plan.internalFieldPaths)
856
+
857
+ sendSSEPageMeta(res, total, hasMore)
858
+ sendSSERootArray(res, publicRows)
859
+ sendSSEProgress(res, 'root', 0, plan.stages.length)
860
+
861
+ const stageError = await processFindManyStages({
862
+ extended, models, plan, rootPairs, res, signal,
863
+ })
864
+
865
+ if (isClientGone()) return
866
+
867
+ if (stageError) {
646
868
  if (!res.writableEnded && !res.destroyed) {
647
- sendSSEError(res, stageErrorMessage)
869
+ sendSSEError(res, stageError)
648
870
  }
649
871
  return
650
872
  }
651
873
 
652
874
  if (res.writableEnded || res.destroyed) return
653
- sendSSEResult(res, publicRows)
875
+ sendSSEResult(res, { data: publicRows, total, hasMore })
654
876
  } catch (err) {
655
877
  if (isClientGone()) return
656
- console.error('[auto-progressive] many dispatch error:', err)
878
+ console.error('[auto-progressive] paginated dispatch error:', err)
657
879
  if (!res.writableEnded && !res.destroyed) {
658
880
  sendSSEError(res, mapError(err).message)
659
881
  }
@@ -682,9 +904,12 @@ export async function runAutoIncludeProgressive(
682
904
  return handleAutoIncludeFallback(options, plan.unsupportedReason)
683
905
  }
684
906
 
685
- if (options.baseOp === 'findMany') {
907
+ if (options.baseOp === 'findMany' || options.baseOp === 'findManyPaginated') {
686
908
  const reason = findManyUnsupportedReason(plan)
687
909
  if (reason) return handleAutoIncludeFallback(options, reason)
910
+ } else {
911
+ const reason = singleUnsupportedReason(plan)
912
+ if (reason) return handleAutoIncludeFallback(options, reason)
688
913
  }
689
914
 
690
915
  if (plan.stages.length === 0) {
@@ -698,5 +923,8 @@ export async function runAutoIncludeProgressive(
698
923
  if (options.baseOp === 'findMany') {
699
924
  return runAutoIncludeMany(options, plan)
700
925
  }
926
+ if (options.baseOp === 'findManyPaginated') {
927
+ return runAutoIncludePaginated(options, plan)
928
+ }
701
929
  return runAutoIncludeSingle(options, plan)
702
930
  }
@@ -503,6 +503,23 @@ export function sendSSERelationBatch(
503
503
  return sendSSE(res, { type: 'relationBatch', relationPath, values })
504
504
  }
505
505
 
506
+ export function sendSSENestedRelationBatch(
507
+ res: SseWritable,
508
+ relationPath: string,
509
+ depth: number,
510
+ attachments: Array<{ locator: Array<number | string>; value: unknown }>,
511
+ ): boolean {
512
+ return sendSSE(res, { type: 'nestedRelationBatch', relationPath, depth, attachments })
513
+ }
514
+
515
+ export function sendSSEPageMeta(
516
+ res: SseWritable,
517
+ total: number,
518
+ hasMore: boolean,
519
+ ): boolean {
520
+ return sendSSE(res, { type: 'pageMeta', total, hasMore })
521
+ }
522
+
506
523
  export function sendSSEError(res: SseWritable, message: string): boolean {
507
524
  if (res.writableEnded || res.destroyed) return false
508
525
  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,