@opensaas/stack-core 0.36.0 → 0.38.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 (77) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +121 -0
  3. package/CLAUDE.md +21 -3
  4. package/dist/access/access-filter.d.ts +30 -118
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +70 -206
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +67 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/errors.d.ts +12 -8
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +16 -12
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-visibility.d.ts +2 -1
  27. package/dist/access/field-visibility.d.ts.map +1 -1
  28. package/dist/access/field-visibility.js +91 -17
  29. package/dist/access/field-visibility.js.map +1 -1
  30. package/dist/access/index.d.ts +1 -2
  31. package/dist/access/index.d.ts.map +1 -1
  32. package/dist/access/index.js +1 -1
  33. package/dist/access/index.js.map +1 -1
  34. package/dist/access/relationship-count.d.ts +1 -1
  35. package/dist/config/index.d.ts +1 -1
  36. package/dist/config/index.d.ts.map +1 -1
  37. package/dist/config/types.d.ts +126 -0
  38. package/dist/config/types.d.ts.map +1 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +38 -27
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +28 -5
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/query/index.d.ts +29 -0
  49. package/dist/query/index.d.ts.map +1 -1
  50. package/dist/query/index.js +27 -0
  51. package/dist/query/index.js.map +1 -1
  52. package/dist/query/relationship-options.d.ts +1 -1
  53. package/dist/query/relationship-options.js +1 -1
  54. package/package.json +1 -1
  55. package/src/access/access-filter.test.ts +205 -275
  56. package/src/access/access-filter.ts +84 -267
  57. package/src/access/declared-dependencies.test.ts +277 -0
  58. package/src/access/declared-dependencies.ts +122 -37
  59. package/src/access/depth-limits.ts +8 -7
  60. package/src/access/errors.ts +16 -12
  61. package/src/access/field-visibility.ts +99 -14
  62. package/src/access/index.ts +1 -7
  63. package/src/access/relationship-count.ts +1 -1
  64. package/src/config/index.ts +2 -0
  65. package/src/config/types.ts +130 -0
  66. package/src/context/index.ts +52 -33
  67. package/src/fields/index.ts +35 -5
  68. package/src/index.ts +2 -0
  69. package/src/query/index.ts +53 -0
  70. package/src/query/relationship-options.ts +1 -1
  71. package/tests/access-relationships.test.ts +18 -16
  72. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  73. package/tests/context.test.ts +27 -0
  74. package/tests/field-types.test.ts +12 -0
  75. package/tests/needs-declared-dependencies.test.ts +7 -4
  76. package/tests/resolve-chain.test.ts +11 -11
  77. package/tsconfig.tsbuildinfo +1 -1
@@ -18,199 +18,44 @@ import { AccessScopeDepthExceededError } from './errors.js'
18
18
  * the fetched row, neither of which is expressible in SQL. See
19
19
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
20
20
  * glossary in `CONTEXT.md`.
21
- */
22
-
23
- /** A single relation entry in a Prisma `include` object (see below). */
24
- type IncludeEntry = boolean | { where?: PrismaFilter; include?: IncludeObject; take?: number }
25
- type IncludeObject = Record<string, IncludeEntry>
26
-
27
- /**
28
- * The result of trying to compute an access-controlled include for a list's
29
- * fields. `buildIncludeWithAccessControl` used to collapse three unrelated
30
- * outcomes into a single overloaded `undefined`: "inside a resolveOutput
31
- * context", "hit the depth cap", and "no relationships to scope" all looked
32
- * identical to callers, which is what let a depth-capped relation pass
33
- * through unscoped (issue #830). This discriminated result keeps them
34
- * distinguishable all the way to `mergeIncludeWithAccessControl`, which is the
35
- * only place that knows whether a caller actually asked for the part that
36
- * couldn't be scoped.
37
21
  *
38
- * - `scoped`: relationships were found and (to the extent depth allows)
39
- * access-controlled; `include` is the resulting tree.
40
- * - `nothing-to-scope`: the list genuinely has no relationships to scope, OR
41
- * we are inside a resolveOutput/virtual-field context and deliberately did
42
- * not descend into a relation's own nested relations. Passing the caller's
43
- * include through unchanged here is correct, not a leak.
44
- * - `depth-exceeded`: we could not evaluate this level at all because it sits
45
- * at or past `READ_INCLUDE_MAX_DEPTH`. This is a denial: a caller `include`
46
- * that reaches here must be rejected, not passed through.
47
- */
48
- export type AccessIncludeResult =
49
- | { kind: 'scoped'; include: RichIncludeObject }
50
- | { kind: 'nothing-to-scope' }
51
- | { kind: 'depth-exceeded' }
52
-
53
- /** A relation entry in the rich, provenance-carrying tree `buildIncludeWithAccessControl` builds internally. */
54
- type RichIncludeEntry = { where?: PrismaFilter; nested: AccessIncludeResult }
55
- type RichIncludeObject = Record<string, RichIncludeEntry>
56
-
57
- /**
58
- * Collapse a rich, provenance-carrying include entry down to the plain
59
- * Prisma-shaped form. A `nested` result that is `depth-exceeded` or
60
- * `nothing-to-scope` contributes no `include` key — this is the AUTO-include
61
- * silently stopping, which is correct when no caller asked for anything past
62
- * this point (see `AccessIncludeResult` doc comment).
22
+ * **Caller-directed (ADR-0026).** `buildAccessScopedInclude` walks only the
23
+ * branches `requestedInclude` names the caller's own `include`, a fragment
24
+ * `query`'s projection, or `foldDeclaredDependencies`'s fold of a field's
25
+ * `needs` (`declared-dependencies.ts`), all resolved before this module ever
26
+ * runs. Naming a relation fetches that relation's own columns and stops
27
+ * (the "One hop" rule, see `CONTEXT.md`); reaching further means the request
28
+ * named a nested `include` there too. A relation nobody named never has its
29
+ * list's `query` access evaluated at all there is no separate "build the
30
+ * whole tree, then reconcile against what was asked for" pass to walk it.
63
31
  */
64
- function toPrismaEntry(entry: RichIncludeEntry): IncludeEntry {
65
- const result: { where?: PrismaFilter; include?: IncludeObject } = {}
66
- if (entry.where) result.where = entry.where
67
- if (entry.nested.kind === 'scoped') {
68
- const nestedInclude = toPrismaInclude(entry.nested)
69
- if (nestedInclude && Object.keys(nestedInclude).length > 0) {
70
- result.include = nestedInclude
71
- }
72
- }
73
- return Object.keys(result).length > 0 ? result : true
74
- }
75
-
76
- /**
77
- * Collapse an `AccessIncludeResult` to the plain Prisma `include` shape used
78
- * when there is no caller-supplied include to merge against (the direct
79
- * auto-include path). `nothing-to-scope` and `depth-exceeded` both become
80
- * `undefined` here — at this call site nothing was explicitly requested past
81
- * either boundary, so there is nothing to deny.
82
- */
83
- export function toPrismaInclude(result: AccessIncludeResult): IncludeObject | undefined {
84
- if (result.kind !== 'scoped') return undefined
85
- const out: IncludeObject = {}
86
- for (const [key, entry] of Object.entries(result.include)) {
87
- out[key] = toPrismaEntry(entry)
88
- }
89
- return out
90
- }
91
-
92
- /**
93
- * Build the access-controlled include for a list's fields.
94
- *
95
- * This allows us to filter relationships at the database level instead of in
96
- * memory. Returns an {@link AccessIncludeResult} rather than a plain include
97
- * object so that `mergeIncludeWithAccessControl` can tell a genuine "nothing
98
- * to scope" apart from "the engine hit its depth cap" (see that type's doc
99
- * comment and ADR-0022).
100
- */
101
- export async function buildIncludeWithAccessControl(
102
- fieldConfigs: Record<string, FieldConfig>,
103
- args: {
104
- session: Session | null
105
- context: AccessContext
106
- },
107
- config: OpenSaasConfig,
108
- depth: number = 0,
109
- // List names already on the path from the root to here. Used to detect
110
- // relationship cycles (A → B → … → A) and stop the auto-include from
111
- // re-descending them. Seed it with the root list name at the call site.
112
- visitedLists: readonly string[] = [],
113
- ): Promise<AccessIncludeResult> {
114
- if (depth >= READ_INCLUDE_MAX_DEPTH) {
115
- return { kind: 'depth-exceeded' }
116
- }
117
-
118
- // Inside a resolveOutput/virtual-field context we still scope each immediate
119
- // relation (its own access `where`), but do not auto-expand INTO its nested
120
- // relations — this keeps a single hook-issued read's include from growing
121
- // depth-first. Correction (ADR-0023): this does NOT by itself terminate a
122
- // cycle, despite what an earlier version of this comment claimed. A
123
- // self-referential relation read from inside a hook is a NEW top-level
124
- // read, invisible to this function's own recursion — the loop runs through
125
- // the hook↔read boundary, not through repeated calls to
126
- // `buildIncludeWithAccessControl`. Loop termination is the resolve chain's
127
- // job: `field-visibility.ts`'s `resolveReadableFieldValue` refuses to
128
- // re-enter a `(list, field)` pair already on the chain. What this check
129
- // still does correctly is row-scope the immediate relation rather than
130
- // skipping scoping altogether (issue #830's second trigger).
131
- const insideResolveOutput = args.context._resolveOutputChain.length > 0
132
32
 
133
- const include: RichIncludeObject = {}
134
- let hasRelationships = false
33
+ /** The structured (object) form of a relation include entry caller/fold-supplied or produced by this module. */
34
+ type IncludeEntryObject = { where?: PrismaFilter; include?: Record<string, unknown>; take?: number }
135
35
 
136
- for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
137
- if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
138
- hasRelationships = true
139
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
140
-
141
- if (relatedConfig) {
142
- // Check query access for the related list
143
- const queryAccess = relatedConfig.listConfig.access?.operation?.query
144
- const accessResult = await checkAccess(queryAccess, {
145
- session: args.session,
146
- context: args.context,
147
- })
148
-
149
- // If access is completely denied, exclude this relationship
150
- if (accessResult === false) {
151
- continue
152
- }
153
-
154
- const where = typeof accessResult === 'object' ? accessResult : undefined
155
-
156
- // Cycle guard: if the related list already appears on the path from the
157
- // root, DO NOT auto-include its relationships again. On a cyclic
158
- // readable-relationship graph (A → B → … → A) the depth-first walk would
159
- // otherwise re-descend the cycle on every branch, and — combined with the
160
- // bare-`true` leaf re-expansion in `mergeIncludeWithAccessControl` — build
161
- // an include tree deep/large enough to overflow the call stack in
162
- // downstream processing (the RSC serializer's recursive `Map.set`). This
163
- // extends the SF-20 (#566) fix: a bare-`true` leaf must resolve to a
164
- // genuine single-level fetch, not a re-expansion of the full auto-include.
165
- // The relation itself is still included (as a FLAT fetch of its own
166
- // columns); only its onward relationships are pruned at the back-edge.
167
- let nested: AccessIncludeResult = { kind: 'nothing-to-scope' }
168
- const relatedListName = relatedConfig.listName
169
- if (!insideResolveOutput && !visitedLists.includes(relatedListName)) {
170
- nested = await buildIncludeWithAccessControl(
171
- relatedConfig.listConfig.fields,
172
- args,
173
- config,
174
- depth + 1,
175
- [...visitedLists, relatedListName],
176
- )
177
- }
178
-
179
- include[fieldName] = { where, nested }
180
- }
181
- }
182
- }
183
-
184
- return hasRelationships ? { kind: 'scoped', include } : { kind: 'nothing-to-scope' }
36
+ /** A plain object — excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
37
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
185
39
  }
186
40
 
187
- /** The structured (object) form of a relation include entry. */
188
- type IncludeEntryObject = { where?: PrismaFilter; include?: IncludeObject; take?: number }
189
-
190
41
  /**
191
42
  * Narrow an unknown include value to the structured object form (vs bare `true`
192
43
  * or any other primitive). Caller-supplied includes arrive untyped at the
193
- * runtime boundary, so we validate the shape here rather than casting.
44
+ * runtime boundary: narrow to a plain object first, then validate each field's
45
+ * own type before trusting it, rather than casting the whole value wholesale.
194
46
  *
195
47
  * A numeric `take` on a to-many relation include (a caller-supplied row bound,
196
48
  * issue #752) is carried through: it only ever NARROWS the fetched rows and can
197
- * never widen past the access `where`, so preserving it is access-neutral. The
198
- * access-controlled include never sets `take` itself — it originates solely
199
- * from the caller — so `mergeIncludeWithAccessControl` re-attaches it below.
49
+ * never widen past the access `where`, so preserving it is access-neutral.
200
50
  */
201
51
  function asEntryObject(value: unknown): IncludeEntryObject | null {
202
- if (value && typeof value === 'object') {
203
- const obj = value as Record<string, unknown>
204
- const where = obj.where
205
- const include = obj.include
206
- const take = obj.take
207
- const entry: IncludeEntryObject = {}
208
- if (where && typeof where === 'object') entry.where = where as PrismaFilter
209
- if (include && typeof include === 'object') entry.include = include as IncludeObject
210
- if (typeof take === 'number') entry.take = take
211
- return entry
212
- }
213
- return null
52
+ if (!isPlainObject(value)) return null
53
+ const { where, include, take } = value
54
+ const entry: IncludeEntryObject = {}
55
+ if (isPlainObject(where)) entry.where = where
56
+ if (isPlainObject(include)) entry.include = include
57
+ if (typeof take === 'number') entry.take = take
58
+ return entry
214
59
  }
215
60
 
216
61
  /**
@@ -232,128 +77,100 @@ function andWhere(
232
77
  }
233
78
 
234
79
  /**
235
- * Merge a caller-supplied `include` with the access-controlled include — phase-1
236
- * row/relation scoping for explicit caller selections.
237
- *
238
- * The caller's `include` decides WHICH relations to fetch; access control decides
239
- * WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
240
- * access-controlled include with the caller's wholesale (the bug in #566) drops
241
- * every per-relation access `where` and denied-relation exclusion, silently
242
- * bypassing row-level access on any non-sudo read that passes `include`.
243
- *
244
- * For each relation the caller asks to include:
245
- * - If the relation is a config-declared relationship but is ABSENT from the
246
- * access-controlled include, its `query` access returned `false` → it is DROPPED
247
- * (not fetched).
248
- * - If it is present (allowed, possibly with a filter), the access entry is used
249
- * as the base: the access `where` is AND-combined with any caller-supplied
250
- * nested `where`, and nested includes are recursively merged using the related
251
- * list's field configs (so deeply-nested selections are filtered at every
252
- * level). A bare caller `true` becomes the access-controlled shape (filter +
253
- * nested filtered include), never bare `true`.
254
- * - If the caller names a key that is NOT a config-declared relationship, it is
255
- * passed through unchanged (access control does not govern it).
80
+ * Build the access-scoped `include` for exactly the relations a read
81
+ * requested, recursing only into branches `requestedInclude` itself names.
256
82
  *
257
- * `accessControlledInclude` is the {@link AccessIncludeResult} for THIS level:
258
- * - `nothing-to-scope`nothing to merge against (the list has no
259
- * relationships, or we're inside a resolveOutput context where the caller
260
- * include is irrelevant to begin with). Pass the caller's include through
261
- * unchanged this is a non-denial outcome, not "every relation denied".
262
- * - `depth-exceeded`the engine could not compute a scope for THIS level at
263
- * all because it sits at or past `READ_INCLUDE_MAX_DEPTH`. If the caller
264
- * named anything here, that is exactly the case that used to pass through
265
- * unscoped (issue #830): throw `AccessScopeDepthExceededError` instead. An
266
- * empty caller include at this level (nothing further requested) is not an
267
- * error there's simply nothing to do.
268
- * - `scoped` the normal per-relation merge below: a declared relationship
269
- * ABSENT from the access include was denied (drop it); one PRESENT is used
270
- * as the base, AND-combining `where`s and recursing into nested includes.
83
+ * For each key in `requestedInclude`:
84
+ * - Not a config-declared relationship access control does not govern it;
85
+ * passed through unchanged (e.g. a fragment/caller key that isn't a
86
+ * relationship at all).
87
+ * - A declared relationship whose related list's `query` access denies it
88
+ * (`=== false`)dropped entirely, no matter what the request asked for
89
+ * nested beneath it (#566): the caller chooses *which* relations, access
90
+ * control chooses *whether* and *with what filter*.
91
+ * - Otherwise the access `where` is AND-combined with any caller-supplied
92
+ * nested `where` (never replaced the other half of #566), a
93
+ * caller-supplied `take` rides through unchanged (#752), and — the "One
94
+ * hop" rule (ADR-0026) nested relations are scoped ONLY if
95
+ * `requestedInclude` itself named a nested `include` here. A bare relation
96
+ * (or one with no nested `include`) fetches its own columns and stops: no
97
+ * recursive call, no access evaluation on anything beneath it.
271
98
  *
272
- * `listKey` and `depth` are carried only to build a useful
273
- * `AccessScopeDepthExceededError` message; they do not affect merge behaviour.
99
+ * **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
100
+ * is always a finite literal the caller's own object, or
101
+ * `foldDeclaredDependencies`'s already-cycle-guarded fold — so this recursion
102
+ * cannot loop unboundedly on its own; nothing here walks the relationship
103
+ * graph unprompted. `READ_INCLUDE_MAX_DEPTH` still bounds how deep a request
104
+ * may reach, fail-closed per ADR-0022: a request naming anything at or past
105
+ * the cap throws `AccessScopeDepthExceededError` rather than silently
106
+ * returning less than what was asked for.
274
107
  */
275
- export function mergeIncludeWithAccessControl(
276
- callerInclude: Record<string, unknown>,
277
- accessControlledInclude: AccessIncludeResult,
108
+ export async function buildAccessScopedInclude(
109
+ requestedInclude: Record<string, unknown>,
278
110
  fieldConfigs: Record<string, FieldConfig>,
111
+ args: {
112
+ session: Session | null
113
+ context: AccessContext
114
+ },
279
115
  config: OpenSaasConfig,
280
116
  listKey: string,
281
117
  depth: number = 0,
282
- ): Record<string, unknown> {
283
- if (accessControlledInclude.kind === 'nothing-to-scope') {
284
- return callerInclude
285
- }
286
-
287
- if (accessControlledInclude.kind === 'depth-exceeded') {
288
- const [firstRelationName] = Object.keys(callerInclude)
289
- if (firstRelationName !== undefined) {
290
- throw new AccessScopeDepthExceededError(listKey, firstRelationName, depth)
291
- }
292
- return {}
118
+ ): Promise<Record<string, unknown>> {
119
+ const requestedKeys = Object.keys(requestedInclude)
120
+ if (depth >= READ_INCLUDE_MAX_DEPTH && requestedKeys.length > 0) {
121
+ throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth)
293
122
  }
294
123
 
295
- const merged: Record<string, unknown> = {}
296
- const accessInclude = accessControlledInclude.include
124
+ const result: Record<string, unknown> = {}
297
125
 
298
- for (const [relationName, callerValue] of Object.entries(callerInclude)) {
126
+ for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
299
127
  const fieldConfig = fieldConfigs[relationName]
300
128
  const isDeclaredRelationship =
301
129
  fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
302
130
 
303
- // Not a config-declared relationship → access control does not govern it; pass through unchanged.
304
131
  if (!isDeclaredRelationship) {
305
- merged[relationName] = callerValue
132
+ result[relationName] = requestedValue
306
133
  continue
307
134
  }
308
135
 
309
- const accessEntry = accessInclude[relationName]
136
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
137
+ if (!relatedConfig) continue
138
+
139
+ const queryAccess = relatedConfig.listConfig.access?.operation?.query
140
+ const accessResult = await checkAccess(queryAccess, {
141
+ session: args.session,
142
+ context: args.context,
143
+ })
310
144
 
311
- // Declared relationship absent from the access include → query access denied → drop it.
312
- if (accessEntry === undefined) {
145
+ if (accessResult === false) {
313
146
  continue
314
147
  }
315
148
 
316
- const callerEntry = asEntryObject(callerValue)
149
+ const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
150
+ const requestedEntry = asEntryObject(requestedValue)
151
+ const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
317
152
 
318
- // Resolve the related list's field configs so nested includes merge recursively.
319
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
320
- const relatedFields = relatedConfig?.listConfig.fields
321
-
322
- const mergedWhere = andWhere(accessEntry.where, callerEntry?.where)
323
-
324
- let mergedNested: Record<string, unknown> | undefined
325
- if (callerEntry?.include && relatedFields && relatedConfig) {
326
- // Recurse: scope the caller's nested selection against the nested access
327
- // result. If that result is 'depth-exceeded', the recursive call itself
328
- // throws — the caller named something the engine cannot scope.
329
- mergedNested = mergeIncludeWithAccessControl(
330
- callerEntry.include,
331
- accessEntry.nested,
332
- relatedFields,
153
+ let nestedInclude: Record<string, unknown> | undefined
154
+ if (requestedEntry?.include) {
155
+ nestedInclude = await buildAccessScopedInclude(
156
+ requestedEntry.include,
157
+ relatedConfig.listConfig.fields,
158
+ args,
333
159
  config,
334
160
  relatedConfig.listName,
335
161
  depth + 1,
336
162
  )
337
- } else if (accessEntry.nested.kind === 'scoped') {
338
- // Caller selected the relation bare (no nested include); keep the
339
- // access-controlled nested include so deeper relations stay filtered.
340
- mergedNested = toPrismaInclude(accessEntry.nested)
341
163
  }
342
- // If the caller's entry has no nested include, a 'depth-exceeded' nested
343
- // result is silent here too — nothing was asked for past this point.
344
164
 
345
165
  const entry: { where?: PrismaFilter; include?: Record<string, unknown>; take?: number } = {}
346
166
  if (mergedWhere) entry.where = mergedWhere
347
- if (mergedNested && Object.keys(mergedNested).length > 0) entry.include = mergedNested
348
- // Preserve a caller-supplied row bound on the relation (issue #752). It only
349
- // narrows, never widens, so it rides on top of the access `where`/include.
350
- if (callerEntry?.take !== undefined) entry.take = callerEntry.take
167
+ if (nestedInclude && Object.keys(nestedInclude).length > 0) entry.include = nestedInclude
168
+ if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
351
169
 
352
- // A bare-`true` relation with no access filter, nested include, or row bound stays `true`.
353
- merged[relationName] = Object.keys(entry).length > 0 ? entry : true
170
+ result[relationName] = Object.keys(entry).length > 0 ? entry : true
354
171
  }
355
172
 
356
- return merged
173
+ return result
357
174
  }
358
175
 
359
176
  /**