@opensaas/stack-core 0.37.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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +68 -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/context/index.d.ts.map +1 -1
  36. package/dist/context/index.js +38 -27
  37. package/dist/context/index.js.map +1 -1
  38. package/dist/query/index.d.ts +29 -0
  39. package/dist/query/index.d.ts.map +1 -1
  40. package/dist/query/index.js +27 -0
  41. package/dist/query/index.js.map +1 -1
  42. package/dist/query/relationship-options.d.ts +1 -1
  43. package/dist/query/relationship-options.js +1 -1
  44. package/package.json +1 -1
  45. package/src/access/access-filter.test.ts +205 -275
  46. package/src/access/access-filter.ts +84 -267
  47. package/src/access/declared-dependencies.test.ts +277 -0
  48. package/src/access/declared-dependencies.ts +122 -37
  49. package/src/access/depth-limits.ts +8 -7
  50. package/src/access/errors.ts +16 -12
  51. package/src/access/field-visibility.ts +99 -14
  52. package/src/access/index.ts +1 -7
  53. package/src/access/relationship-count.ts +1 -1
  54. package/src/context/index.ts +52 -33
  55. package/src/query/index.ts +53 -0
  56. package/src/query/relationship-options.ts +1 -1
  57. package/tests/access-relationships.test.ts +18 -16
  58. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  59. package/tests/context.test.ts +27 -0
  60. package/tests/needs-declared-dependencies.test.ts +7 -4
  61. package/tests/resolve-chain.test.ts +11 -11
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -6,6 +6,7 @@ import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
6
6
  import { ResolveOutputCycleError } from './errors.js'
7
7
  import type { DeclaredOnlyTree } from './declared-dependencies.js'
8
8
  import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
9
+ import type { FieldSelectionScope } from '../query/index.js'
9
10
  // NOTE: `context/index.ts` imports `filterReadableFields` from this module
10
11
  // (via the `access/index.ts` barrel) — this is an intentional cyclic
11
12
  // dependency, the same shape and for the same reason as the one documented in
@@ -23,6 +24,14 @@ import { buildDbDelegate } from '../context/index.js'
23
24
  * virtual fields. None of this can move into phase 1: virtual fields are
24
25
  * computed in JavaScript and field access can depend on the fetched row.
25
26
  *
27
+ * A computed field — any field carrying a `resolveOutput` hook, virtual or
28
+ * not — is produced only where the read is going to return it (ADR-0027). A
29
+ * fragment `query`'s own field selection is the only thing that restricts a
30
+ * level this way; a bare or `include`-based read, and any relation reached
31
+ * purely to satisfy a `needs` declaration, still compute every field, as
32
+ * before. A field the read is not going to return does no work at all —
33
+ * neither its read-access evaluation nor its hook.
34
+ *
26
35
  * Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
27
36
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
28
37
  * glossary in `CONTEXT.md`.
@@ -96,8 +105,11 @@ function deriveResolveOutputContext(
96
105
  * from the result.
97
106
  *
98
107
  * `accessItem` is the row used to evaluate field access; `hookItem` is the
99
- * object passed to the hook as `item` (these differ for virtual fields, which
100
- * see the already-filtered output so they can read sibling fields).
108
+ * object passed to the hook as `item`. For a stored field, both are
109
+ * `workingItem` (the row's own stored/fetched columns). For a virtual field,
110
+ * `hookItem` is `computedFieldItem` instead — the same stored columns with
111
+ * every skipped-or-denied key removed — so it never sees another computed
112
+ * field's resolved value (ADR-0027).
101
113
  */
102
114
  async function resolveReadableFieldValue(params: {
103
115
  fieldConfig: FieldConfig | undefined
@@ -189,6 +201,13 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
189
201
  // returned — after resolveOutput has had a chance to read them — so a
190
202
  // declared dependency never widens what the caller receives.
191
203
  declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
204
+ // The fragment scope this level was reached under (ADR-0027), and the same
205
+ // tree one level down for each nested relation. `undefined` — the default,
206
+ // and what a bare/`include`-based read passes at every level — means
207
+ // unrestricted: every field on the list is computed, unchanged from
208
+ // before ADR-0027. Only a `query` fragment's own field selection ever
209
+ // restricts a level.
210
+ selection?: FieldSelectionScope,
192
211
  ): Promise<Partial<T>> {
193
212
  const filtered: Record<string, unknown> = {}
194
213
 
@@ -214,6 +233,15 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
214
233
  workingItem[fieldName] = assembled
215
234
  }
216
235
 
236
+ // Keys denied by field-level read access during the pass below — as opposed
237
+ // to a key merely skipped by `selection` or held back only for
238
+ // `declaredOnly` stripping. Tracked separately because a denied key must
239
+ // stay invisible to a computed field's hook (below), while a declared-only
240
+ // key must stay VISIBLE to one — that is the entire point of declaring it
241
+ // (ADR-0025) — even though `selection` above skipped adding it to
242
+ // `filtered` because the caller's fragment never asked for it.
243
+ const accessDeniedKeys = new Set<string>()
244
+
217
245
  // Process existing fields from the database result
218
246
  for (const [fieldName, value] of Object.entries(workingItem)) {
219
247
  const fieldConfig = fieldConfigs[fieldName]
@@ -224,8 +252,19 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
224
252
  continue
225
253
  }
226
254
 
255
+ // Projection-aware skip (ADR-0027): a fragment read that does not select
256
+ // this field does no work for it at all — no field-level read-access
257
+ // check, no resolveOutput, no recursion into a relation — because the
258
+ // read is never going to return it. `selection` is only ever restricted
259
+ // by a fragment's own field selection; a bare/`include`-based read, and a
260
+ // relation reached only to satisfy another field's `needs`, pass no
261
+ // selection at all and compute every field here, unchanged.
262
+ if (selection?.fields && !selection.fields.has(fieldName)) {
263
+ continue
264
+ }
265
+
227
266
  // Handle relationship fields - recursively filter fields within related items
228
- // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
267
+ // Note: Access control filtering is now done at database level via buildAccessScopedInclude
229
268
  // This only handles field-level access (hiding sensitive fields)
230
269
  //
231
270
  // Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
@@ -251,6 +290,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
251
290
  })
252
291
 
253
292
  if (!canRead) {
293
+ accessDeniedKeys.add(fieldName)
254
294
  continue
255
295
  }
256
296
 
@@ -260,6 +300,12 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
260
300
  // back to an empty tree when this relation isn't declaration-related at
261
301
  // all — the common case.
262
302
  const nestedDeclaredOnly = declaredOnly.nested[fieldName] ?? emptyDeclaredOnlyTree()
303
+ // This relation's own fragment scope, if the caller's fragment named it
304
+ // with a nested Fragment/RelationSelector. `undefined` (a bare `true`
305
+ // selector, or no `selection` at all) means the nested list computes
306
+ // unrestricted — matching what naming a relation without narrowing it
307
+ // further has always meant.
308
+ const nestedSelection = selection?.nested[fieldName]
263
309
 
264
310
  if (relatedConfig) {
265
311
  // For many relationships (arrays) - recursively filter fields in each item
@@ -275,6 +321,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
275
321
  depth + 1,
276
322
  relatedConfig.listName,
277
323
  nestedDeclaredOnly,
324
+ nestedSelection,
278
325
  ),
279
326
  ),
280
327
  )
@@ -290,6 +337,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
290
337
  depth + 1,
291
338
  relatedConfig.listName,
292
339
  nestedDeclaredOnly,
340
+ nestedSelection,
293
341
  )
294
342
  }
295
343
  } else {
@@ -314,7 +362,36 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
314
362
 
315
363
  if (result.readable) {
316
364
  filtered[fieldName] = result.value
365
+ } else {
366
+ accessDeniedKeys.add(fieldName)
367
+ }
368
+ }
369
+
370
+ // The item a virtual field's hook sees: stored columns and fetched
371
+ // relations (from `workingItem`, never a resolved value — no hook's output
372
+ // is ever written back into `workingItem`). A key is visible here if it
373
+ // either survived into `filtered` (selected and allowed) OR exists only to
374
+ // satisfy a `needs` declaration (`declaredOnly` — that IS the point of
375
+ // declaring it: fetched for a hook, never for the caller, ADR-0025).
376
+ // Everything else — field-level denied, or skipped by `selection` and
377
+ // declared by no one — is deleted. A computed field reaches for exactly its
378
+ // own declared dependencies and nothing another field's hook produced
379
+ // (ADR-0027): reaching for a sibling that was denied or skipped-and-
380
+ // undeclared finds nothing there, the same as reaching for one never
381
+ // declared at all, and reaching for a sibling that DID survive finds its
382
+ // raw stored form, never another hook's resolved value — a virtual field
383
+ // computed earlier in declaration order is exactly as invisible as one
384
+ // computed later.
385
+ const computedFieldItem: Record<string, unknown> = { ...workingItem }
386
+ for (const key of Object.keys(workingItem)) {
387
+ if (['id', 'createdAt', 'updatedAt'].includes(key)) continue
388
+ if (accessDeniedKeys.has(key)) {
389
+ delete computedFieldItem[key]
390
+ continue
317
391
  }
392
+ if (key in filtered) continue
393
+ if (declaredOnly.keys.has(key)) continue
394
+ delete computedFieldItem[key]
318
395
  }
319
396
 
320
397
  // Process virtual fields - compute values from other fields
@@ -330,22 +407,29 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
330
407
  continue
331
408
  }
332
409
 
333
- // Virtual fields must have a resolveOutput hook to compute their value;
334
- // without one there is nothing to add to the result.
410
+ // Projection-aware skip (ADR-0027): same rule as the stored-field pass
411
+ // above a fragment that does not select this virtual field does no
412
+ // work for it at all.
413
+ if (selection?.fields && !selection.fields.has(fieldName)) {
414
+ continue
415
+ }
416
+
417
+ // A virtual field with no resolveOutput hook can never produce a value
418
+ // on ANY read — there is nothing to compute, so there is nothing to do,
419
+ // including evaluating its read access (ADR-0027 reconciles the
420
+ // access-only evaluation this branch used to preserve: a field that
421
+ // never has output has no side effect worth preserving access for).
335
422
  if (!(fieldConfig.hooks?.resolveOutput && listKey)) {
336
- // Still evaluate read access to preserve any access-fn side effects.
337
- await checkFieldAccess(fieldConfig.access, 'read', { ...args, item: workingItem })
338
423
  continue
339
424
  }
340
425
 
341
- // Check read access and compute the value via the shared helper. Virtual
342
- // fields see the already-filtered item so they can read sibling fields.
426
+ // Check read access and compute the value via the shared helper.
343
427
  const result = await resolveReadableFieldValue({
344
428
  fieldConfig,
345
429
  fieldName,
346
430
  value: undefined, // Virtual fields don't have a database value
347
431
  accessItem: workingItem,
348
- hookItem: filtered,
432
+ hookItem: computedFieldItem,
349
433
  listKey,
350
434
  args,
351
435
  config,
@@ -357,10 +441,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
357
441
  }
358
442
 
359
443
  // Strip relations that were fetched ONLY to satisfy a `needs` declaration
360
- // (ADR-0025), now that every resolveOutput hook at this level including
361
- // virtual fields, which read the assembled `filtered` object above has
362
- // had the chance to see them. A declared dependency is private plumbing,
363
- // not an implicit `include`: it never widens what the caller receives.
444
+ // (ADR-0025), now that every resolveOutput hook at this level has had the
445
+ // chance to see them (via `computedFieldItem`, never `filtered` itselfa
446
+ // declared dependency is read from stored columns, not from another
447
+ // field's resolved output). A declared dependency is private plumbing, not
448
+ // an implicit `include`: it never widens what the caller receives.
364
449
  for (const key of declaredOnly.keys) {
365
450
  delete filtered[key]
366
451
  }
@@ -22,13 +22,7 @@ export {
22
22
  // Canonical field-level access evaluation (shared by read and write paths).
23
23
  export { checkFieldAccess, filterWritableFields } from './field-access.js'
24
24
  // Phase 1 — Access Filter (pre-query row/relation scoping).
25
- export {
26
- buildIncludeWithAccessControl,
27
- mergeIncludeWithAccessControl,
28
- stripVirtualFieldsFromInclude,
29
- toPrismaInclude,
30
- } from './access-filter.js'
31
- export type { AccessIncludeResult } from './access-filter.js'
25
+ export { buildAccessScopedInclude, stripVirtualFieldsFromInclude } from './access-filter.js'
32
26
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
33
27
  export { filterReadableFields } from './field-visibility.js'
34
28
  // Declared Dependencies — folding `needs` into an include without widening
@@ -15,7 +15,7 @@ import { getDbKey } from '../lib/case-utils.js'
15
15
  * per-row query is issued and the count can never include rows the session
16
16
  * cannot read. This module is the single place the related list's
17
17
  * operation-level `query` access is folded into that `_count`, mirroring how
18
- * `buildIncludeWithAccessControl` folds it into relation includes.
18
+ * `buildAccessScopedInclude` folds it into relation includes.
19
19
  *
20
20
  * It also resolves the count Filter spec's markers: Prisma cannot compare a
21
21
  * relation count in a `where`, so a to-many relationship's Filter spec emits a
@@ -4,8 +4,7 @@ import {
4
4
  checkAccess,
5
5
  mergeFilters,
6
6
  filterReadableFields,
7
- buildIncludeWithAccessControl,
8
- mergeIncludeWithAccessControl,
7
+ buildAccessScopedInclude,
9
8
  stripVirtualFieldsFromInclude,
10
9
  foldDeclaredDependencies,
11
10
  } from '../access/index.js'
@@ -13,8 +12,8 @@ import type { DeclaredOnlyTree } from '../access/index.js'
13
12
  import { ValidationError, DatabaseError } from '../hooks/index.js'
14
13
  import { getDbKey } from '../lib/case-utils.js'
15
14
  import type { PrismaClientLike } from '../access/types.js'
16
- import { buildInclude, pickFields, isFragment } from '../query/index.js'
17
- import type { FieldSelection } from '../query/index.js'
15
+ import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
16
+ import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
18
17
  import { getRelationshipOptions } from '../query/relationship-options.js'
19
18
  import {
20
19
  runWritePipeline,
@@ -921,11 +920,19 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
921
920
  *
922
921
  * A fragment's own `include` and a sudo caller's `include` are folded and
923
922
  * used as-is, matching their existing (unmerged) treatment. A non-sudo
924
- * caller include is folded and then merged through the same
925
- * access-scoping pipeline as before. A bare read stays on the exact
926
- * ADR-0024 path `include: undefined`, no related `query` access
927
- * evaluated unless folding actually added something, which only happens
928
- * when a field on this list declares `needs`.
923
+ * caller include is folded and then scoped by `buildAccessScopedInclude`
924
+ * (ADR-0026) caller-directed, so a relation named nowhere in the folded
925
+ * tree never has its list's `query` access evaluated at all. A bare read
926
+ * stays on the exact ADR-0024 path `include: undefined`, no related
927
+ * `query` access evaluated unless folding actually added something, which
928
+ * only happens when a field on this list declares `needs`.
929
+ *
930
+ * Also returns the `FieldSelectionScope` a fragment's own field selection
931
+ * produces (ADR-0027), so the caller can pass it to `filterReadableFields`
932
+ * and make computation itself projection-aware, not only the fold above.
933
+ * `undefined` for every non-fragment path: a caller `include` (sudo or not)
934
+ * and a bare read both mean "compute every field," matching what they
935
+ * already fetch.
929
936
  */
930
937
  async function resolveReadInclude(
931
938
  callerInclude: Record<string, unknown> | undefined,
@@ -935,38 +942,43 @@ async function resolveReadInclude(
935
942
  listConfig: ListConfig<any>,
936
943
  context: AccessContext & { _isSudo?: boolean },
937
944
  config: OpenSaasConfig,
938
- ): Promise<{ include: Record<string, unknown> | undefined; declaredOnly: DeclaredOnlyTree }> {
945
+ ): Promise<{
946
+ include: Record<string, unknown> | undefined
947
+ declaredOnly: DeclaredOnlyTree
948
+ selection: FieldSelectionScope | undefined
949
+ }> {
939
950
  if (fragmentFields !== undefined) {
940
951
  const fragmentInclude = buildInclude(fragmentFields) ?? undefined
941
- return foldDeclaredDependencies(fragmentInclude, listConfig.fields, config)
952
+ const selection = buildFieldSelectionScope(fragmentFields)
953
+ const folded = foldDeclaredDependencies(
954
+ fragmentInclude,
955
+ listConfig.fields,
956
+ config,
957
+ listName,
958
+ [listName],
959
+ selection,
960
+ )
961
+ return { ...folded, selection }
942
962
  }
943
963
 
944
964
  if (context._isSudo) {
945
- return foldDeclaredDependencies(callerInclude, listConfig.fields, config)
965
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
966
+ return { ...folded, selection: undefined }
946
967
  }
947
968
 
948
- const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config)
969
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
949
970
  if (!folded.include) {
950
- return folded
971
+ return { ...folded, selection: undefined }
951
972
  }
952
973
 
953
- const accessControlledInclude = await buildIncludeWithAccessControl(
954
- listConfig.fields,
955
- { session: context.session, context },
956
- config,
957
- 0,
958
- // Seed the cycle guard with the root list so a relationship cycle back
959
- // to it (self-referential or longer) stops re-descending.
960
- [listName],
961
- )
962
- const include = mergeIncludeWithAccessControl(
974
+ const include = await buildAccessScopedInclude(
963
975
  folded.include,
964
- accessControlledInclude,
965
976
  listConfig.fields,
977
+ { session: context.session, context },
966
978
  config,
967
979
  listName,
968
980
  )
969
- return { include, declaredOnly: folded.declaredOnly }
981
+ return { include, declaredOnly: folded.declaredOnly, selection: undefined }
970
982
  }
971
983
 
972
984
  /**
@@ -1032,7 +1044,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1032
1044
  // Resolve `include`, folding any declared dependencies (`needs`,
1033
1045
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1034
1046
  // already produces — see `resolveReadInclude`'s doc comment.
1035
- let { include, declaredOnly } = await resolveReadInclude(
1047
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1036
1048
  args.include,
1037
1049
  fragment ? fragment._fields : undefined,
1038
1050
  listName,
@@ -1044,8 +1056,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1044
1056
  // Virtual fields have no database column. Whichever path produced
1045
1057
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
1046
1058
  // virtual key must never reach Prisma — it would throw "Unknown field"
1047
- // (#628). The virtual value is still computed unconditionally below by
1048
- // `filterReadableFields`, independent of what was requested here.
1059
+ // (#628). Below, `filterReadableFields` computes a virtual field's value
1060
+ // exactly when `selection` says the read is going to return it (ADR-0027)
1061
+ // — every one of them for a bare/`include`-based read (`selection` is
1062
+ // `undefined`), only the ones a fragment named otherwise.
1049
1063
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1050
1064
 
1051
1065
  // Execute query with optimized includes
@@ -1074,6 +1088,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1074
1088
  0,
1075
1089
  listName,
1076
1090
  declaredOnly,
1091
+ selection,
1077
1092
  )
1078
1093
 
1079
1094
  // When a fragment is provided, pick only the requested fields from the result
@@ -1145,7 +1160,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1145
1160
  // Resolve `include`, folding any declared dependencies (`needs`,
1146
1161
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1147
1162
  // already produces — see `resolveReadInclude`'s doc comment.
1148
- let { include, declaredOnly } = await resolveReadInclude(
1163
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1149
1164
  args?.include,
1150
1165
  fragment ? fragment._fields : undefined,
1151
1166
  listName,
@@ -1157,8 +1172,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1157
1172
  // Virtual fields have no database column. Whichever path produced
1158
1173
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
1159
1174
  // virtual key must never reach Prisma — it would throw "Unknown field"
1160
- // (#628). The virtual value is still computed unconditionally below by
1161
- // `filterReadableFields`, independent of what was requested here.
1175
+ // (#628). Below, `filterReadableFields` computes a virtual field's value
1176
+ // exactly when `selection` says the read is going to return it (ADR-0027)
1177
+ // — every one of them for a bare/`include`-based read (`selection` is
1178
+ // `undefined`), only the ones a fragment named otherwise.
1162
1179
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1163
1180
 
1164
1181
  // Execute query with optimized includes
@@ -1188,6 +1205,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1188
1205
  0,
1189
1206
  listName,
1190
1207
  declaredOnly,
1208
+ selection,
1191
1209
  ),
1192
1210
  ),
1193
1211
  )
@@ -1461,7 +1479,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1461
1479
  // Resolve `include`, folding any declared dependencies (`needs`,
1462
1480
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1463
1481
  // already produces — see `resolveReadInclude`'s doc comment.
1464
- let { include, declaredOnly } = await resolveReadInclude(
1482
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1465
1483
  args?.include,
1466
1484
  fragment ? fragment._fields : undefined,
1467
1485
  listName,
@@ -1493,6 +1511,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1493
1511
  0,
1494
1512
  listName,
1495
1513
  declaredOnly,
1514
+ selection,
1496
1515
  )
1497
1516
  // When a fragment is provided, pick only the requested fields from the result
1498
1517
  if (fragment) {
@@ -345,6 +345,59 @@ export function buildInclude(fields: FieldSelection<unknown>): Record<string, un
345
345
  return hasIncludes ? include : undefined
346
346
  }
347
347
 
348
+ /**
349
+ * A snapshot of which field names a fragment selects at one nesting level,
350
+ * plus the same tree one level down for every relation selected via a nested
351
+ * Fragment/RelationSelector. `fields: undefined` means "unrestricted" — every
352
+ * field at this level is going to be returned, which is what a bare or
353
+ * `include`-based read means for the whole tree (only a `query` fragment ever
354
+ * produces a restricted scope, and only as deep as it names).
355
+ *
356
+ * Used to make computed-field evaluation (`filterReadableFields`) and
357
+ * declared-dependency folding (`foldDeclaredDependencies`) projection-aware
358
+ * (ADR-0027): a field not named by the scope at its level is never computed
359
+ * and its `needs` are never folded, because the read is never going to
360
+ * return it.
361
+ * @internal
362
+ */
363
+ export type FieldSelectionScope = {
364
+ readonly fields: ReadonlySet<string> | undefined
365
+ readonly nested: Readonly<Record<string, FieldSelectionScope>>
366
+ }
367
+
368
+ /**
369
+ * Build the `FieldSelectionScope` for one fragment's field selection,
370
+ * recursing into nested Fragment/RelationSelector entries the same way
371
+ * `buildInclude` does. A relation named with the bare `true` shorthand (no
372
+ * narrower nested Fragment) gets no entry in `nested`, so a level reached
373
+ * through it is treated as unrestricted — the caller asked for "everything"
374
+ * there and gave no narrower shape to restrict it with.
375
+ * @internal
376
+ */
377
+ export function buildFieldSelectionScope(fields: FieldSelection<unknown>): FieldSelectionScope {
378
+ const fieldNames = new Set(Object.keys(fields as Record<string, unknown>))
379
+ const nested: Record<string, FieldSelectionScope> = {}
380
+
381
+ for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
382
+ if (value === null || value === true || typeof value !== 'object') continue
383
+ const val = value as Record<string, unknown>
384
+
385
+ if (isFragment(val)) {
386
+ nested[key] = buildFieldSelectionScope(val._fields as FieldSelection<unknown>)
387
+ continue
388
+ }
389
+
390
+ if ('query' in val && isFragment(val.query)) {
391
+ nested[key] = buildFieldSelectionScope(
392
+ (val.query as Fragment<unknown, FieldSelection<unknown>>)
393
+ ._fields as FieldSelection<unknown>,
394
+ )
395
+ }
396
+ }
397
+
398
+ return { fields: fieldNames, nested }
399
+ }
400
+
348
401
  /**
349
402
  * Recursively pick only the fields requested by a fragment from a raw Prisma
350
403
  * result object. This ensures the runtime shape exactly matches the type
@@ -23,7 +23,7 @@ export interface RelationshipOptionsArgs {
23
23
  * editor — the read primitive behind the `relationshipOptions` serverAction
24
24
  * op. Selects only `id` and the resolved label field (via
25
25
  * {@link getLabelFieldName}), so the fragment carries no relation keys and
26
- * `buildIncludeWithAccessControl`'s depth-5 auto-include never runs.
26
+ * `buildAccessScopedInclude` never has anything to scope.
27
27
  *
28
28
  * Operation-level `query` access on `relatedListKey` still applies — a denied
29
29
  * list resolves to `[]` (via the underlying access-controlled `findMany`).
@@ -123,7 +123,7 @@ describe('Relationship Access Control', () => {
123
123
  expect(result.author?.name).toBe('John Doe')
124
124
  })
125
125
 
126
- it('should filter out single relationship when access denied (via buildIncludeWithAccessControl)', async () => {
126
+ it('should filter out single relationship when access denied (via buildAccessScopedInclude)', async () => {
127
127
  const config: OpenSaasConfig = {
128
128
  db: {
129
129
  provider: 'postgresql',
@@ -152,16 +152,18 @@ describe('Relationship Access Control', () => {
152
152
  },
153
153
  }
154
154
 
155
- // Test that buildIncludeWithAccessControl excludes the denied relationship
156
- const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
155
+ // Test that buildAccessScopedInclude excludes the denied relationship
156
+ const { buildAccessScopedInclude } = await import('../src/access/index.js')
157
157
 
158
- const include = await buildIncludeWithAccessControl(
158
+ const include = await buildAccessScopedInclude(
159
+ { author: true },
159
160
  config.lists.Post.fields,
160
161
  {
161
162
  session: null,
162
163
  context: mockContext,
163
164
  },
164
165
  config,
166
+ 'Post',
165
167
  )
166
168
 
167
169
  // When access is denied, the relationship should not be included
@@ -286,7 +288,7 @@ describe('Relationship Access Control', () => {
286
288
  expect(result.posts?.[1].title).toBe('Post 2')
287
289
  })
288
290
 
289
- it('should filter items in many relationships based on query access (via buildIncludeWithAccessControl)', async () => {
291
+ it('should filter items in many relationships based on query access (via buildAccessScopedInclude)', async () => {
290
292
  const config: OpenSaasConfig = {
291
293
  db: {
292
294
  provider: 'postgresql',
@@ -318,19 +320,19 @@ describe('Relationship Access Control', () => {
318
320
  },
319
321
  }
320
322
 
321
- // Test that buildIncludeWithAccessControl creates the right where clause
322
- const { buildIncludeWithAccessControl, toPrismaInclude } =
323
- await import('../src/access/index.js')
323
+ // Test that buildAccessScopedInclude creates the right where clause
324
+ const { buildAccessScopedInclude } = await import('../src/access/index.js')
324
325
 
325
- const result = await buildIncludeWithAccessControl(
326
+ const include = await buildAccessScopedInclude(
327
+ { posts: true },
326
328
  config.lists.User.fields,
327
329
  {
328
330
  session: null,
329
331
  context: mockContext,
330
332
  },
331
333
  config,
334
+ 'User',
332
335
  )
333
- const include = toPrismaInclude(result)
334
336
 
335
337
  // Should include posts with a where filter
336
338
  expect(include).toBeDefined()
@@ -452,7 +454,7 @@ describe('Relationship Access Control', () => {
452
454
  })
453
455
 
454
456
  describe('session-based access for relationships', () => {
455
- it('should apply session-based access to relationships (via buildIncludeWithAccessControl)', async () => {
457
+ it('should apply session-based access to relationships (via buildAccessScopedInclude)', async () => {
456
458
  const config: OpenSaasConfig = {
457
459
  db: {
458
460
  provider: 'postgresql',
@@ -487,19 +489,19 @@ describe('Relationship Access Control', () => {
487
489
  },
488
490
  }
489
491
 
490
- // Test that buildIncludeWithAccessControl creates session-based where clause
491
- const { buildIncludeWithAccessControl, toPrismaInclude } =
492
- await import('../src/access/index.js')
492
+ // Test that buildAccessScopedInclude creates session-based where clause
493
+ const { buildAccessScopedInclude } = await import('../src/access/index.js')
493
494
 
494
- const result = await buildIncludeWithAccessControl(
495
+ const include = await buildAccessScopedInclude(
496
+ { posts: true },
495
497
  config.lists.User.fields,
496
498
  {
497
499
  session: { userId: '1' },
498
500
  context: mockContext,
499
501
  },
500
502
  config,
503
+ 'User',
501
504
  )
502
- const include = toPrismaInclude(result)
503
505
 
504
506
  // Should include posts with session-based where filter
505
507
  expect(include).toBeDefined()