@opensaas/stack-core 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +61 -0
  3. package/dist/access/access-filter.d.ts +76 -20
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +102 -70
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +175 -10
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/depth-limits.d.ts +26 -0
  10. package/dist/access/depth-limits.d.ts.map +1 -0
  11. package/dist/access/depth-limits.js +26 -0
  12. package/dist/access/depth-limits.js.map +1 -0
  13. package/dist/access/errors.d.ts +43 -0
  14. package/dist/access/errors.d.ts.map +1 -0
  15. package/dist/access/errors.js +55 -0
  16. package/dist/access/errors.js.map +1 -0
  17. package/dist/access/field-visibility.d.ts.map +1 -1
  18. package/dist/access/field-visibility.js +82 -20
  19. package/dist/access/field-visibility.js.map +1 -1
  20. package/dist/access/index.d.ts +4 -1
  21. package/dist/access/index.d.ts.map +1 -1
  22. package/dist/access/index.js +5 -1
  23. package/dist/access/index.js.map +1 -1
  24. package/dist/access/multi-column-read-write.test.js +1 -1
  25. package/dist/access/multi-column-read-write.test.js.map +1 -1
  26. package/dist/access/relationship-count.test.js +1 -1
  27. package/dist/access/relationship-count.test.js.map +1 -1
  28. package/dist/access/relationship-label-filter.test.js +1 -1
  29. package/dist/access/relationship-label-filter.test.js.map +1 -1
  30. package/dist/access/types.d.ts +15 -7
  31. package/dist/access/types.d.ts.map +1 -1
  32. package/dist/context/index.d.ts.map +1 -1
  33. package/dist/context/index.js +15 -9
  34. package/dist/context/index.js.map +1 -1
  35. package/dist/context/nested-operations.d.ts +1 -1
  36. package/dist/context/nested-operations.d.ts.map +1 -1
  37. package/dist/context/nested-operations.js +1 -5
  38. package/dist/context/nested-operations.js.map +1 -1
  39. package/dist/context/transaction-boundary.d.ts.map +1 -1
  40. package/dist/context/transaction-boundary.js +43 -6
  41. package/dist/context/transaction-boundary.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts.map +1 -1
  43. package/dist/context/write-pipeline.js +5 -4
  44. package/dist/context/write-pipeline.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -0
  48. package/dist/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/access/access-filter.test.ts +258 -7
  51. package/src/access/access-filter.ts +146 -72
  52. package/src/access/depth-limits.ts +26 -0
  53. package/src/access/errors.ts +62 -0
  54. package/src/access/field-visibility.ts +97 -21
  55. package/src/access/index.ts +6 -0
  56. package/src/access/multi-column-read-write.test.ts +1 -1
  57. package/src/access/relationship-count.test.ts +1 -1
  58. package/src/access/relationship-label-filter.test.ts +1 -1
  59. package/src/access/types.ts +12 -5
  60. package/src/context/index.ts +15 -6
  61. package/src/context/nested-operations.ts +0 -7
  62. package/src/context/transaction-boundary.ts +48 -7
  63. package/src/context/write-pipeline.ts +5 -4
  64. package/src/index.ts +11 -0
  65. package/tests/access-relationships.test.ts +78 -64
  66. package/tests/context.test.ts +106 -24
  67. package/tests/default-value-create.test.ts +1 -1
  68. package/tests/hook-pipeline.test.ts +1 -1
  69. package/tests/nav-count.test.ts +2 -2
  70. package/tests/resolve-chain.test.ts +394 -0
  71. package/tests/transaction-boundary-hooks.test.ts +246 -1
  72. package/tests/write-pipeline.test.ts +1 -1
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Maximum nesting depth of relation `include`s that the Access Filter
3
+ * (`buildIncludeWithAccessControl`) will auto-scope on a read.
4
+ *
5
+ * Security implication: this is an access-control boundary, not just a cost
6
+ * bound. Past this depth the engine cannot compute a row/field scope for a
7
+ * relation, so a caller-supplied `include` naming a relation at or beyond it
8
+ * must be treated as a denial (see `AccessScopeDepthExceededError`) rather
9
+ * than passed through unscoped — see ADR-0022 and issue #830.
10
+ */
11
+ export const READ_INCLUDE_MAX_DEPTH = 5
12
+
13
+ /**
14
+ * Maximum length of the resolve chain — the ordered sequence of `resolveOutput`
15
+ * hooks a read has entered, each entry extended by a hook that issues its own
16
+ * read (see `_resolveOutputChain` on `AccessContext`).
17
+ *
18
+ * This is a COST limit, not an access-control boundary: an acyclic chain
19
+ * genuinely can exceed it and still be correct (e.g. a virtual `Order.total`
20
+ * reading a virtual `LineItem.subtotal` reading further virtuals), so
21
+ * exceeding it omits the field and emits a single `console.warn` rather than
22
+ * throwing. Termination itself is guaranteed by the separate cycle guard,
23
+ * which refuses to re-enter a `(list, field)` pair already on the chain
24
+ * regardless of this cap. See ADR-0023.
25
+ */
26
+ export const RESOLVE_CHAIN_MAX_LENGTH = 5
@@ -0,0 +1,62 @@
1
+ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
2
+
3
+ /**
4
+ * Thrown when a caller-supplied `include` names a relation nested deeper than
5
+ * the Access Filter can scope (see `READ_INCLUDE_MAX_DEPTH`). Deliberately
6
+ * distinct from `ValidationError`: this is not bad user input, it is the
7
+ * engine refusing to return data it cannot prove is row/field scoped. Code
8
+ * that catches `ValidationError` to report form errors must not silently
9
+ * swallow this.
10
+ *
11
+ * Only an explicit caller selection past the depth cap triggers this — the
12
+ * auto-include silently stopping at the cap (no caller `include` involved)
13
+ * never throws. See ADR-0022 and issue #830.
14
+ */
15
+ export class AccessScopeDepthExceededError extends Error {
16
+ public listKey: string
17
+ public fieldKey: string
18
+ public depth: number
19
+
20
+ constructor(listKey: string, fieldKey: string, depth: number) {
21
+ super(
22
+ `Cannot compute an access scope for "${listKey}.${fieldKey}" at include depth ${depth}: ` +
23
+ `this exceeds the Access Filter's maximum read-include depth (${READ_INCLUDE_MAX_DEPTH}). ` +
24
+ `A caller-supplied include this deep cannot be row- and field-scoped, so the read is denied ` +
25
+ `rather than returned unscoped. Restructure the query to fetch this relation separately.`,
26
+ )
27
+ this.name = 'AccessScopeDepthExceededError'
28
+ this.listKey = listKey
29
+ this.fieldKey = fieldKey
30
+ this.depth = depth
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Thrown when a `resolveOutput` hook re-enters a `(list, field)` pair already
36
+ * on its own resolve chain — a hook whose own read (directly or transitively)
37
+ * comes back around to itself. Deliberately distinct from `ValidationError`
38
+ * (same reasoning as `AccessScopeDepthExceededError`): this is not bad user
39
+ * input, it is the engine refusing to run a hook chain that cannot terminate.
40
+ *
41
+ * This is a loud failure, not a Silent one, on purpose: a repeated pair never
42
+ * terminated before this guard existed, so no working application can depend
43
+ * on the old (hanging) behaviour, and staying silent here would return
44
+ * `undefined` for a field with nothing in the logs to explain why. Contrast
45
+ * with exceeding `RESOLVE_CHAIN_MAX_LENGTH`, which can happen on a chain that
46
+ * would otherwise terminate correctly and therefore only warns. See ADR-0023.
47
+ */
48
+ export class ResolveOutputCycleError extends Error {
49
+ public chain: readonly { listKey: string; fieldKey: string }[]
50
+
51
+ constructor(chain: readonly { listKey: string; fieldKey: string }[]) {
52
+ const path = chain.map((link) => `${link.listKey}.${link.fieldKey}`).join(' → ')
53
+ super(
54
+ `resolveOutput cycle detected: ${path}. A hook that re-enters a (list, field) pair ` +
55
+ `already on its own resolve chain cannot terminate, so the read is refused rather than ` +
56
+ `left to recurse until the process runs out of memory. Restructure the hooks so the read ` +
57
+ `does not loop back into itself.`,
58
+ )
59
+ this.name = 'ResolveOutputCycleError'
60
+ this.chain = chain
61
+ }
62
+ }
@@ -2,6 +2,15 @@ import type { Session, AccessContext } from './types.js'
2
2
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
3
3
  import { getRelatedListConfig } from './engine.js'
4
4
  import { checkFieldAccess } from './field-access.js'
5
+ import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
6
+ import { ResolveOutputCycleError } from './errors.js'
7
+ // NOTE: `context/index.ts` imports `filterReadableFields` from this module
8
+ // (via the `access/index.ts` barrel) — this is an intentional cyclic
9
+ // dependency, the same shape and for the same reason as the one documented in
10
+ // `context/write-pipeline.ts`. `buildDbDelegate` is only INVOKED when a
11
+ // `resolveOutput` hook actually runs (never during module evaluation), so by
12
+ // the time it runs the export is fully initialised.
13
+ import { buildDbDelegate } from '../context/index.js'
5
14
 
6
15
  /**
7
16
  * Field Visibility — phase 2 of the two-phase read (post-query).
@@ -36,6 +45,44 @@ type FieldVisibilityArgs = {
36
45
  context: AccessContext & { _isSudo?: boolean }
37
46
  }
38
47
 
48
+ /**
49
+ * Derive the context passed to a single `resolveOutput` hook invocation: a
50
+ * NEW context object whose `_resolveOutputChain` extends the caller's chain
51
+ * with this hook's own `(list, field)` link. The chain is never mutated in
52
+ * place — this is what lets concurrent hook invocations (e.g. sibling rows in
53
+ * a to-many relation, filtered via `Promise.all`) each see their own chain
54
+ * rather than racing on one shared value (ADR-0023).
55
+ *
56
+ * A plain `{ ...context, _resolveOutputChain }` spread is not enough on its
57
+ * own: `context.db`'s operations capture their `context` at construction
58
+ * (see `populateDbDelegate`), so a hook that calls `context.db.x.findMany(…)`
59
+ * would otherwise reach the ORIGINAL closures — bound to the ORIGINAL
60
+ * context — and its read would silently fall back to the un-extended chain,
61
+ * defeating the cycle guard entirely. Rebuilding `db` via `buildDbDelegate`
62
+ * against the derived context is what makes a hook-issued read's own nested
63
+ * hooks actually observe the extended chain.
64
+ *
65
+ * `config` is required to rebuild `db`; callers that cannot supply one (e.g. a
66
+ * narrow unit test exercising field access in isolation) still get a correct
67
+ * chain for THIS hook's own cycle/cap check, but a read that hook issues
68
+ * would not carry the chain any further — those callers are not exercising
69
+ * the read pipeline, so there is nothing for it to reach.
70
+ */
71
+ function deriveResolveOutputContext(
72
+ context: AccessContext & { _isSudo?: boolean },
73
+ link: { listKey: string; fieldKey: string },
74
+ config: OpenSaasConfig | undefined,
75
+ ): AccessContext & { _isSudo?: boolean } {
76
+ const derived: AccessContext & { _isSudo?: boolean } = {
77
+ ...context,
78
+ _resolveOutputChain: [...context._resolveOutputChain, link],
79
+ }
80
+ if (config) {
81
+ derived.db = buildDbDelegate(config, context.prisma, derived)
82
+ }
83
+ return derived
84
+ }
85
+
39
86
  /**
40
87
  * The core Field Visibility step for a single field: check read access and, if
41
88
  * granted, produce the output value by running any `resolveOutput` hook.
@@ -58,8 +105,9 @@ async function resolveReadableFieldValue(params: {
58
105
  hookItem: Record<string, unknown>
59
106
  listKey: string | undefined
60
107
  args: FieldVisibilityArgs
108
+ config: OpenSaasConfig | undefined
61
109
  }): Promise<{ readable: false } | { readable: true; value: unknown }> {
62
- const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args } = params
110
+ const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args, config } = params
63
111
 
64
112
  // Check field access (checkFieldAccess already handles sudo mode)
65
113
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
@@ -76,25 +124,44 @@ async function resolveReadableFieldValue(params: {
76
124
  // Cast to runtime type for generic execution
77
125
  // At runtime, the hook will receive the correct value type for the field
78
126
  const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
79
- // Increment depth counter to prevent infinite loops from hooks making DB queries
80
- // that include relationships back to the same entity
81
- args.context._resolveOutputCounter.depth++
82
- try {
83
- // Use Promise.resolve() to handle both sync and async hooks
84
- const resolved = await Promise.resolve(
85
- hook({
86
- value,
87
- operation: 'query',
88
- fieldName,
89
- listKey,
90
- item: hookItem,
91
- context: args.context,
92
- }),
127
+ const link = { listKey, fieldKey: fieldName }
128
+ const chain = args.context._resolveOutputChain
129
+
130
+ // Cycle guard: a hook that would re-enter a (list, field) pair already on
131
+ // its own chain cannot terminate refuse loudly rather than recurse
132
+ // until the process runs out of memory (issue #844, ADR-0023).
133
+ const alreadyOnChain = chain.some(
134
+ (entry) => entry.listKey === link.listKey && entry.fieldKey === link.fieldKey,
135
+ )
136
+ if (alreadyOnChain) {
137
+ throw new ResolveOutputCycleError([...chain, link])
138
+ }
139
+
140
+ // Cost cap: a chain this long is refused only as a cost limit, never a
141
+ // correctness one — an acyclic chain that works today can legitimately
142
+ // reach this. Omit the field and warn instead of throwing.
143
+ if (chain.length >= RESOLVE_CHAIN_MAX_LENGTH) {
144
+ const path = [...chain, link].map((entry) => `${entry.listKey}.${entry.fieldKey}`).join(' → ')
145
+ console.warn(
146
+ `resolveOutput: omitting "${listKey}.${fieldName}" — its resolve chain exceeded ` +
147
+ `RESOLVE_CHAIN_MAX_LENGTH (${RESOLVE_CHAIN_MAX_LENGTH}): ${path}. This is a cost limit, ` +
148
+ `not an access denial.`,
93
149
  )
94
- return { readable: true, value: resolved }
95
- } finally {
96
- args.context._resolveOutputCounter.depth--
150
+ return { readable: false }
97
151
  }
152
+
153
+ // Use Promise.resolve() to handle both sync and async hooks
154
+ const resolved = await Promise.resolve(
155
+ hook({
156
+ value,
157
+ operation: 'query',
158
+ fieldName,
159
+ listKey,
160
+ item: hookItem,
161
+ context: deriveResolveOutputContext(args.context, link, config),
162
+ }),
163
+ )
164
+ return { readable: true, value: resolved }
98
165
  }
99
166
 
100
167
  return { readable: true, value }
@@ -116,7 +183,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
116
183
  listKey?: string,
117
184
  ): Promise<Partial<T>> {
118
185
  const filtered: Record<string, unknown> = {}
119
- const MAX_DEPTH = 5 // Prevent infinite recursion
120
186
 
121
187
  // Multi-column fields (e.g. storage image()/file() in Keystone-parity mode)
122
188
  // back several physical columns rather than one. Before the per-field pass,
@@ -153,14 +219,22 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
153
219
  // Handle relationship fields - recursively filter fields within related items
154
220
  // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
155
221
  // This only handles field-level access (hiding sensitive fields)
222
+ //
223
+ // Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
224
+ // what gets FETCHED (a caller include past its depth cap is now a denial,
225
+ // not a passthrough — see ADR-0022), so by the time a result reaches this
226
+ // function it is already a finite, acyclic tree whose depth was decided at
227
+ // the pre-query phase. Capping recursion again here independently of that
228
+ // cap used to let a relation be scoped correctly at the DB level while
229
+ // still returning with unfiltered fields past this function's own,
230
+ // separately-tracked limit (issue #830).
156
231
  if (
157
232
  config &&
158
233
  fieldConfig?.type === 'relationship' &&
159
234
  'ref' in fieldConfig &&
160
235
  fieldConfig.ref &&
161
236
  value !== null &&
162
- value !== undefined &&
163
- depth < MAX_DEPTH
237
+ value !== undefined
164
238
  ) {
165
239
  // Gate the relationship on read access before recursing.
166
240
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
@@ -220,6 +294,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
220
294
  hookItem: workingItem,
221
295
  listKey,
222
296
  args,
297
+ config,
223
298
  })
224
299
 
225
300
  if (result.readable) {
@@ -258,6 +333,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
258
333
  hookItem: filtered,
259
334
  listKey,
260
335
  args,
336
+ config,
261
337
  })
262
338
 
263
339
  if (result.readable) {
@@ -26,6 +26,12 @@ export {
26
26
  buildIncludeWithAccessControl,
27
27
  mergeIncludeWithAccessControl,
28
28
  stripVirtualFieldsFromInclude,
29
+ toPrismaInclude,
29
30
  } from './access-filter.js'
31
+ export type { AccessIncludeResult } from './access-filter.js'
30
32
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
31
33
  export { filterReadableFields } from './field-visibility.js'
34
+ // Thrown when a caller include reaches past the depth the Access Filter can scope.
35
+ export { AccessScopeDepthExceededError } from './errors.js'
36
+ // Thrown when a resolveOutput hook's own resolve chain cycles back into itself.
37
+ export { ResolveOutputCycleError } from './errors.js'
@@ -52,7 +52,7 @@ function makeContext(overrides: { isSudo?: boolean } = {}): AccessContext {
52
52
  return {
53
53
  session: null,
54
54
  _isSudo: overrides.isSudo ?? false,
55
- _resolveOutputCounter: { depth: 0 },
55
+ _resolveOutputChain: [],
56
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
57
57
  } as any
58
58
  }
@@ -51,7 +51,7 @@ function makeContext(
51
51
  return {
52
52
  session: null,
53
53
  _isSudo: false,
54
- _resolveOutputCounter: { depth: 0 },
54
+ _resolveOutputChain: [],
55
55
  db: findMany ? { user: { findMany } } : {},
56
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
57
57
  } as any
@@ -46,7 +46,7 @@ function makeContext(): AccessContext {
46
46
  return {
47
47
  session: null,
48
48
  _isSudo: false,
49
- _resolveOutputCounter: { depth: 0 },
49
+ _resolveOutputChain: [],
50
50
  db: {},
51
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
52
52
  } as any
@@ -290,12 +290,19 @@ export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLi
290
290
  plugins: Record<string, unknown>
291
291
  _isSudo: boolean
292
292
  /**
293
- * Internal mutable counter to track resolveOutput hook depth.
294
- * When depth > 0, we skip auto-including relationships to prevent infinite loops
295
- * when hooks make database queries that include relationships back to the original entity.
296
- * We use a mutable object so that spreading the context preserves the reference.
293
+ * The resolve chain: the ordered sequence of `resolveOutput` hook
294
+ * `(listKey, fieldKey)` pairs a read has entered on the way to here. A
295
+ * top-level read starts with an empty chain. Each hook invocation is given
296
+ * a NEW context whose chain extends this one by its own pair — the chain is
297
+ * never mutated in place, so concurrent hook invocations (e.g. sibling rows
298
+ * in a to-many relation processed via `Promise.all`) never observe each
299
+ * other's chain. A hook that would re-enter a pair already on its own chain
300
+ * is refused (`ResolveOutputCycleError`) rather than left to recurse
301
+ * forever; a chain longer than `RESOLVE_CHAIN_MAX_LENGTH` is a separate,
302
+ * non-fatal cost limit. See ADR-0023 and the "Resolve chain" glossary entry
303
+ * in CONTEXT.md.
297
304
  */
298
- _resolveOutputCounter: { depth: number }
305
+ _resolveOutputChain: readonly { listKey: string; fieldKey: string }[]
299
306
  }
300
307
 
301
308
  /**
@@ -7,6 +7,7 @@ import {
7
7
  buildIncludeWithAccessControl,
8
8
  mergeIncludeWithAccessControl,
9
9
  stripVirtualFieldsFromInclude,
10
+ toPrismaInclude,
10
11
  } from '../access/index.js'
11
12
  import { ValidationError, DatabaseError } from '../hooks/index.js'
12
13
  import { getDbKey } from '../lib/case-utils.js'
@@ -433,7 +434,7 @@ export function getContext<
433
434
  // client, otherwise start empty and populate via plugin runtimes below.
434
435
  plugins: _sharedPlugins ?? {},
435
436
  _isSudo,
436
- _resolveOutputCounter: { depth: 0 },
437
+ _resolveOutputChain: [],
437
438
  }
438
439
 
439
440
  // Create access-controlled operations for each list, populating `db` in place.
@@ -994,15 +995,19 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
994
995
  // MERGE (not replace) a caller-supplied include with the access-controlled
995
996
  // include: the caller selects WHICH relations to fetch, access control
996
997
  // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
997
- // caller include) still uses the access-controlled include directly.
998
+ // caller include) still uses the access-controlled include directly. A
999
+ // caller include naming a relation past the depth the engine can scope
1000
+ // throws `AccessScopeDepthExceededError` (issue #830) rather than being
1001
+ // returned unscoped.
998
1002
  include = args.include
999
1003
  ? mergeIncludeWithAccessControl(
1000
1004
  args.include,
1001
1005
  accessControlledInclude,
1002
1006
  listConfig.fields,
1003
1007
  config,
1008
+ listName,
1004
1009
  )
1005
- : accessControlledInclude
1010
+ : toPrismaInclude(accessControlledInclude)
1006
1011
  }
1007
1012
 
1008
1013
  // Virtual fields have no database column. Whichever path produced
@@ -1128,15 +1133,19 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1128
1133
  // MERGE (not replace) a caller-supplied include with the access-controlled
1129
1134
  // include: the caller selects WHICH relations to fetch, access control
1130
1135
  // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
1131
- // caller include) still uses the access-controlled include directly.
1136
+ // caller include) still uses the access-controlled include directly. A
1137
+ // caller include naming a relation past the depth the engine can scope
1138
+ // throws `AccessScopeDepthExceededError` (issue #830) rather than being
1139
+ // returned unscoped.
1132
1140
  include = args?.include
1133
1141
  ? mergeIncludeWithAccessControl(
1134
1142
  args.include,
1135
1143
  accessControlledInclude,
1136
1144
  listConfig.fields,
1137
1145
  config,
1146
+ listName,
1138
1147
  )
1139
- : accessControlledInclude
1148
+ : toPrismaInclude(accessControlledInclude)
1140
1149
  }
1141
1150
 
1142
1151
  // Virtual fields have no database column. Whichever path produced
@@ -1445,7 +1454,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1445
1454
  // Try to find the record
1446
1455
  const item = await model.findFirst({
1447
1456
  where,
1448
- include: accessControlledInclude,
1457
+ include: toPrismaInclude(accessControlledInclude),
1449
1458
  })
1450
1459
 
1451
1460
  // If record exists, return it
@@ -1312,17 +1312,10 @@ export async function processNestedOperations(
1312
1312
  // finding) so item-/inputData-dependent field-access rules cannot diverge between
1313
1313
  // Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
1314
1314
  parentInputData: Record<string, unknown> | undefined = undefined,
1315
- depth: number = 0,
1316
1315
  ): Promise<NestedOpsResult> {
1317
- const MAX_DEPTH = 5
1318
-
1319
1316
  const afterTasks: AfterTask[] = []
1320
1317
  const includeFields = new Set<string>()
1321
1318
 
1322
- if (depth >= MAX_DEPTH) {
1323
- return { data, afterTasks, includeFields }
1324
- }
1325
-
1326
1319
  const processed: Record<string, unknown> = {}
1327
1320
 
1328
1321
  for (const [fieldName, value] of Object.entries(data)) {
@@ -55,9 +55,6 @@ export interface InvolvedList {
55
55
  originalItem: Record<string, unknown> | undefined
56
56
  }
57
57
 
58
- /** Max nesting depth walked when enumerating involved lists (matches nested-operations). */
59
- const MAX_DEPTH = 5
60
-
61
58
  /** Nested-op kinds whose payloads imply an involved list + operation. */
62
59
  const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperation }> = [
63
60
  { kind: 'create', operation: 'create' },
@@ -67,10 +64,49 @@ const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperat
67
64
  { kind: 'connectOrCreate', operation: 'create' },
68
65
  ]
69
66
 
67
+ /** Distinct dedupe-key operations `NESTED_OP_OPERATIONS` can produce (create/update/delete). */
68
+ const DISTINCT_OPERATION_COUNT = new Set(NESTED_OP_OPERATIONS.map((o) => o.operation)).size
69
+
70
70
  function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
71
71
  return fieldConfig?.type === 'relationship'
72
72
  }
73
73
 
74
+ /**
75
+ * The number of distinct (listKey, operation) involvement pairs the walk
76
+ * could ever record starting from `startListName` — computed from the
77
+ * CONFIG's relationship graph (not the payload), so it bounds the walk by
78
+ * what the schema can reach rather than by an arbitrary depth.
79
+ *
80
+ * Used as the saturation bound: once `walkNested` has recorded this many
81
+ * pairs, no further pair can be new, so it stops descending. This replaces
82
+ * the old depth cap as the cost bound (#835) — a payload nesting the config's
83
+ * lists more deeply than any previous cap no longer loses their
84
+ * transaction-boundary hooks, while a payload that repeats the same few
85
+ * lists still terminates promptly instead of walking every entry.
86
+ */
87
+ function countReachableInvolvementPairs(
88
+ startListName: string,
89
+ startListConfig: ListConfig<any>, // eslint-disable-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
90
+ config: OpenSaasConfig,
91
+ ): number {
92
+ const visited = new Set<string>([startListName])
93
+ const queue: Array<ListConfig<any>> = [startListConfig] // eslint-disable-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
94
+
95
+ while (queue.length > 0) {
96
+ const current = queue.shift()!
97
+ for (const fieldConfig of Object.values(current.fields)) {
98
+ if (!isRelationshipField(fieldConfig)) continue
99
+ const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
100
+ const related = getRelatedListConfig(relationshipField.ref, config)
101
+ if (!related || visited.has(related.listName)) continue
102
+ visited.add(related.listName)
103
+ queue.push(related.listConfig)
104
+ }
105
+ }
106
+
107
+ return visited.size * DISTINCT_OPERATION_COUNT
108
+ }
109
+
74
110
  function asRecordArray(value: unknown): Array<Record<string, unknown>> {
75
111
  if (value == null) return []
76
112
  if (Array.isArray(value)) return value.filter((v) => v && typeof v === 'object')
@@ -117,9 +153,11 @@ function walkNested(
117
153
  config: OpenSaasConfig,
118
154
  out: InvolvedList[],
119
155
  seen: Set<string>,
120
- depth: number,
156
+ maxPairs: number,
121
157
  ): void {
122
- if (!data || depth >= MAX_DEPTH) return
158
+ // Every reachable pair is already recorded — no further recursion can add
159
+ // anything new, so stop instead of re-walking the rest of the payload.
160
+ if (!data || seen.size >= maxPairs) return
123
161
 
124
162
  for (const [fieldName, value] of Object.entries(data)) {
125
163
  const fieldConfig = fieldConfigs[fieldName]
@@ -150,10 +188,12 @@ function walkNested(
150
188
  })
151
189
  }
152
190
 
191
+ if (seen.size >= maxPairs) return
192
+
153
193
  // Recurse into each nested entry's own relationship payload.
154
194
  for (const entry of entries) {
155
195
  const childData = nestedInputData(kind, entry)
156
- walkNested(childData, relatedListConfig.fields, config, out, seen, depth + 1)
196
+ walkNested(childData, relatedListConfig.fields, config, out, seen, maxPairs)
157
197
  }
158
198
  }
159
199
  }
@@ -187,9 +227,10 @@ export function enumerateInvolvedLists(args: {
187
227
  },
188
228
  ]
189
229
  const seen = new Set<string>([`${listName}:${operation}`])
230
+ const maxPairs = countReachableInvolvementPairs(listName, listConfig, config)
190
231
 
191
232
  // Delete has no nested payload to walk (inputData is undefined).
192
- walkNested(inputData, listConfig.fields, config, out, seen, 0)
233
+ walkNested(inputData, listConfig.fields, config, out, seen, maxPairs)
193
234
 
194
235
  return out
195
236
  }
@@ -297,9 +297,10 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
297
297
  * construction, so the request-time `context.db` is bound to the ORIGINAL
298
298
  * client. We rebuild the delegates against `tx` via {@link buildDbDelegate},
299
299
  * reusing the request context's `session`, `storage`, `plugins`, `_isSudo`, and
300
- * the shared `_resolveOutputCounter` reference (so resolveOutput depth tracking
301
- * is preserved). Plugin runtimes are NOT re-executed; the existing
302
- * `plugins` object is reused as-is.
300
+ * the current `_resolveOutputChain` value (carried through unchanged, so a
301
+ * write issued from inside a `resolveOutput` hook keeps that hook's chain).
302
+ * Plugin runtimes are NOT re-executed; the existing `plugins` object is
303
+ * reused as-is.
303
304
  */
304
305
  function bindContextToTransaction<TPrisma extends PrismaClientLike>(
305
306
  args: WritePipelineArgs<TPrisma>,
@@ -313,7 +314,7 @@ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
313
314
  storage: context.storage,
314
315
  plugins: context.plugins,
315
316
  _isSudo: context._isSudo,
316
- _resolveOutputCounter: context._resolveOutputCounter,
317
+ _resolveOutputChain: context._resolveOutputChain,
317
318
  }
318
319
  // Rebuild the db delegate against `tx`, pointing back at `txContext` so hooks
319
320
  // reached through it also see the transactional context.
package/src/index.ts CHANGED
@@ -62,6 +62,17 @@ export { resolveNavCounts, isListQueryStaticallyDenied } from './config/nav-coun
62
62
  // Validation error surfaced by write operations
63
63
  export { ValidationError } from './hooks/index.js'
64
64
 
65
+ // Thrown by a read when a caller-supplied `include` names a relation nested
66
+ // deeper than the Access Filter can scope (see ADR-0022). Distinct from
67
+ // `ValidationError` — this is the engine refusing to return unscoped data, not
68
+ // a user-input validation failure.
69
+ export { AccessScopeDepthExceededError } from './access/index.js'
70
+
71
+ // Thrown by a `resolveOutput` hook whose own read cycles back into a
72
+ // `(list, field)` pair already on its resolve chain (see ADR-0023). Distinct
73
+ // from `ValidationError` for the same reason as `AccessScopeDepthExceededError`.
74
+ export { ResolveOutputCycleError } from './access/index.js'
75
+
65
76
  // Field self-containment validation — checks each field implements the
66
77
  // generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
67
78
  // getPrismaRelation for relationships) so a misimplemented field fails early