@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
@@ -0,0 +1,277 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { foldDeclaredDependencies, emptyDeclaredOnlyTree } from './declared-dependencies.js'
3
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
4
+
5
+ /**
6
+ * Unit coverage for `foldDeclaredDependencies` (ADR-0025), focused on the
7
+ * behaviour ADR-0026 requires of it once the read pipeline stopped
8
+ * auto-expanding a named relation's own subtree: a declared dependency no
9
+ * longer rides that expansion "for free" and must be folded recursively —
10
+ * at every level a field is computed, however that level was reached
11
+ * (declaration-added, caller-named bare, or caller-named with its own nested
12
+ * include).
13
+ *
14
+ * End-to-end coverage of the fold feeding an actual read (mocked Prisma, real
15
+ * `resolveOutput` hooks) lives in `tests/needs-declared-dependencies.test.ts`;
16
+ * these tests exercise the fold function directly so a regression here fails
17
+ * fast with a small, precise reproduction.
18
+ */
19
+
20
+ function relField(ref: string, many = false): FieldConfig {
21
+ return { type: 'relationship', ref, many } as unknown as FieldConfig
22
+ }
23
+
24
+ function virtualNeeds(needs: string[]): FieldConfig {
25
+ return {
26
+ type: 'virtual',
27
+ needs,
28
+ hooks: { resolveOutput: () => 'x' },
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
30
+ } as any
31
+ }
32
+
33
+ describe('foldDeclaredDependencies', () => {
34
+ it('returns the exact same reference when there is nothing to fold', () => {
35
+ const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
36
+ const config = { db: { provider: 'sqlite' }, lists: {} } as unknown as OpenSaasConfig
37
+
38
+ const result = foldDeclaredDependencies(undefined, fields, config, 'List')
39
+ expect(result.include).toBeUndefined()
40
+ expect(result.declaredOnly).toEqual(emptyDeclaredOnlyTree())
41
+ })
42
+
43
+ it('folds a declaration-added bare relation whose own related list has further needs', () => {
44
+ // Order.total needs lineItems (declaration-added, no caller include at
45
+ // all). LineItem.summary needs product. Without recursing into the
46
+ // declaration-added branch, `product` would never be fetched, and
47
+ // LineItem.summary would silently compute over `undefined`.
48
+ const config: OpenSaasConfig = {
49
+ db: { provider: 'sqlite' },
50
+ lists: {
51
+ Order: {
52
+ fields: {
53
+ lineItems: relField('LineItem.order', true),
54
+ total: virtualNeeds(['lineItems']),
55
+ },
56
+ },
57
+ LineItem: {
58
+ fields: { product: relField('Product'), summary: virtualNeeds(['product']) },
59
+ },
60
+ Product: { fields: { name: { type: 'text' } as FieldConfig } },
61
+ },
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
63
+ } as any
64
+
65
+ const result = foldDeclaredDependencies(undefined, config.lists.Order.fields, config, 'Order')
66
+
67
+ expect(result.include).toEqual({ lineItems: { include: { product: true } } })
68
+ // The whole `lineItems` branch is declaration-only at Order's level, so it
69
+ // needs no fine-grained nested tracking — the entire branch is stripped
70
+ // regardless of what's nested inside it.
71
+ expect(result.declaredOnly.keys.has('lineItems')).toBe(true)
72
+ expect(result.declaredOnly.nested.lineItems).toBeUndefined()
73
+ })
74
+
75
+ it('folds a caller-named BARE relation whose related list has its own needs, tracking the addition for fine-grained stripping', () => {
76
+ // The caller explicitly asked for `lineItems` (bare) — NOT declaration-only
77
+ // at Order's level — but LineItem's own `product` still has to be folded
78
+ // in for LineItem.summary's hook. Because the caller DID ask for
79
+ // `lineItems`, `product` must be tracked as declaration-only ONE LEVEL
80
+ // DOWN so it (and only it) gets stripped from each returned line item.
81
+ const config: OpenSaasConfig = {
82
+ db: { provider: 'sqlite' },
83
+ lists: {
84
+ Order: {
85
+ fields: {
86
+ lineItems: relField('LineItem.order', true),
87
+ total: virtualNeeds(['lineItems']),
88
+ },
89
+ },
90
+ LineItem: {
91
+ fields: {
92
+ product: relField('Product'),
93
+ tag: relField('Tag'),
94
+ summary: virtualNeeds(['product']),
95
+ },
96
+ },
97
+ Product: { fields: { name: { type: 'text' } as FieldConfig } },
98
+ Tag: { fields: { name: { type: 'text' } as FieldConfig } },
99
+ },
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
101
+ } as any
102
+
103
+ const result = foldDeclaredDependencies(
104
+ { lineItems: true },
105
+ config.lists.Order.fields,
106
+ config,
107
+ 'Order',
108
+ )
109
+
110
+ expect(result.include).toEqual({ lineItems: { include: { product: true } } })
111
+ // `lineItems` itself is caller-named, not declaration-only...
112
+ expect(result.declaredOnly.keys.has('lineItems')).toBe(false)
113
+ // ...but `product`, nested beneath it, IS — so it gets stripped from each
114
+ // line item while `lineItems` (and its other fields, e.g. `tag`) survive.
115
+ expect(result.declaredOnly.nested.lineItems?.keys.has('product')).toBe(true)
116
+ })
117
+
118
+ it("recurses into an explicit caller-nested include so the nested list's own needs are satisfied there too", () => {
119
+ const config: OpenSaasConfig = {
120
+ db: { provider: 'sqlite' },
121
+ lists: {
122
+ Order: { fields: { lineItems: relField('LineItem.order', true) } },
123
+ LineItem: {
124
+ fields: {
125
+ product: relField('Product'),
126
+ tag: relField('Tag'),
127
+ summary: virtualNeeds(['product']),
128
+ },
129
+ },
130
+ Product: { fields: { name: { type: 'text' } as FieldConfig } },
131
+ Tag: { fields: { name: { type: 'text' } as FieldConfig } },
132
+ },
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
134
+ } as any
135
+
136
+ const result = foldDeclaredDependencies(
137
+ { lineItems: { include: { tag: true } } },
138
+ config.lists.Order.fields,
139
+ config,
140
+ 'Order',
141
+ )
142
+
143
+ expect(result.include).toEqual({ lineItems: { include: { tag: true, product: true } } })
144
+ expect(result.declaredOnly.nested.lineItems?.keys.has('product')).toBe(true)
145
+ expect(result.declaredOnly.nested.lineItems?.keys.has('tag')).toBe(false)
146
+ })
147
+
148
+ it('does not fold anything beneath a relation whose related list has no needs of its own', () => {
149
+ const config: OpenSaasConfig = {
150
+ db: { provider: 'sqlite' },
151
+ lists: {
152
+ Order: { fields: { customer: relField('Customer') } },
153
+ Customer: { fields: { name: { type: 'text' } as FieldConfig } },
154
+ },
155
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
156
+ } as any
157
+
158
+ const result = foldDeclaredDependencies(
159
+ { customer: true },
160
+ config.lists.Order.fields,
161
+ config,
162
+ 'Order',
163
+ )
164
+
165
+ // Untouched — no needs anywhere in reach, so the bare relation stays bare.
166
+ expect(result.include).toEqual({ customer: true })
167
+ expect(result.declaredOnly).toEqual(emptyDeclaredOnlyTree())
168
+ })
169
+
170
+ it("folds a revisited list's own needs beneath a relation the request named", () => {
171
+ // The request names Post → author → posts, revisiting Post. Nothing here
172
+ // can loop — the include is a finite literal the caller wrote out — so the
173
+ // fold must keep going and satisfy Post.blurb's `needs` at THAT level too.
174
+ // Guarding a caller-named edge on list identity would stop the fold at
175
+ // `posts` and leave `blurb` computing over an undefined `tags`.
176
+ const config: OpenSaasConfig = {
177
+ db: { provider: 'sqlite' },
178
+ lists: {
179
+ Post: {
180
+ fields: {
181
+ author: relField('User.posts'),
182
+ comments: relField('Comment', true),
183
+ tags: relField('Tag', true),
184
+ blurb: virtualNeeds(['tags']),
185
+ },
186
+ },
187
+ User: { fields: { posts: relField('Post.author', true) } },
188
+ Comment: { fields: { body: { type: 'text' } as FieldConfig } },
189
+ Tag: { fields: { label: { type: 'text' } as FieldConfig } },
190
+ },
191
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
192
+ } as any
193
+
194
+ const result = foldDeclaredDependencies(
195
+ { author: { include: { posts: { include: { comments: true } } } } },
196
+ config.lists.Post.fields,
197
+ config,
198
+ 'Post',
199
+ )
200
+
201
+ expect(result.include).toEqual({
202
+ tags: true,
203
+ author: { include: { posts: { include: { comments: true, tags: true } } } },
204
+ })
205
+ // Only the nested `tags` needs fine-grained tracking: the root-level one is
206
+ // declaration-only at this level (stripped wholesale), while the nested one
207
+ // sits inside branches the caller named and must be stripped individually.
208
+ expect(result.declaredOnly.keys.has('tags')).toBe(true)
209
+ expect(result.declaredOnly.nested.author?.nested.posts?.keys.has('tags')).toBe(true)
210
+ expect(result.declaredOnly.nested.author?.nested.posts?.keys.has('comments')).toBe(false)
211
+ })
212
+
213
+ it("folds a self-referential relation's own needs when the request names it", () => {
214
+ // `parent` points back at Category itself. The request named it, so the
215
+ // fold must satisfy Category's own `needs` beneath it — `depth` is computed
216
+ // on the parent row too.
217
+ const config: OpenSaasConfig = {
218
+ db: { provider: 'sqlite' },
219
+ lists: {
220
+ Category: {
221
+ fields: {
222
+ parent: relField('Category.children'),
223
+ children: relField('Category.parent', true),
224
+ depth: virtualNeeds(['children']),
225
+ },
226
+ },
227
+ },
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
229
+ } as any
230
+
231
+ const result = foldDeclaredDependencies(
232
+ { parent: true },
233
+ config.lists.Category.fields,
234
+ config,
235
+ 'Category',
236
+ )
237
+
238
+ // `children` folds in at the root AND beneath the caller-named `parent`.
239
+ // The fold beneath `parent` stops there: `children` is declaration-added,
240
+ // so the guard applies to its own onward edge back into Category.
241
+ expect(result.include).toEqual({
242
+ parent: { include: { children: true } },
243
+ children: true,
244
+ })
245
+ expect(result.declaredOnly.keys.has('children')).toBe(true)
246
+ expect(result.declaredOnly.nested.parent?.keys.has('children')).toBe(true)
247
+ })
248
+
249
+ it('defensively terminates a two-list mutual `needs` cycle instead of recursing without bound', () => {
250
+ // Order.total needs lineItems; LineItem.orderRef needs order — a cycle
251
+ // `validateNeedsClosureDepth` (needs-closure.ts) rejects at generate time.
252
+ // This constructs it directly to exercise the fold's OWN defensive guard,
253
+ // independent of that generate-time backstop.
254
+ const config: OpenSaasConfig = {
255
+ db: { provider: 'sqlite' },
256
+ lists: {
257
+ Order: {
258
+ fields: {
259
+ lineItems: relField('LineItem.order', true),
260
+ total: virtualNeeds(['lineItems']),
261
+ },
262
+ },
263
+ LineItem: {
264
+ fields: { order: relField('Order.lineItems'), orderRef: virtualNeeds(['order']) },
265
+ },
266
+ },
267
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
268
+ } as any
269
+
270
+ const result = foldDeclaredDependencies(undefined, config.lists.Order.fields, config, 'Order')
271
+
272
+ // Terminates (would hang/stack-overflow without the guard) and still
273
+ // produces a usable, finite include: `lineItems` folds in, but `order`
274
+ // beneath it stops at a flat fetch rather than looping back to `lineItems` again.
275
+ expect(result.include).toEqual({ lineItems: { include: { order: true } } })
276
+ })
277
+ })
@@ -1,5 +1,6 @@
1
1
  import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
2
2
  import { getRelatedListConfig } from './engine.js'
3
+ import type { FieldSelectionScope } from '../query/index.js'
3
4
 
4
5
  /**
5
6
  * Declared Dependencies — folding a computed field's `needs` into a read's
@@ -8,10 +9,9 @@ import { getRelatedListConfig } from './engine.js'
8
9
  * A field's `needs` declares immediate sibling relations its `resolveOutput`
9
10
  * hook cannot compute without. This module folds those relations into
10
11
  * whatever `include` a read is already building (caller-supplied, fragment-
11
- * derived, or none at all) BEFORE it reaches the existing access-scoping
12
- * pipeline (`buildIncludeWithAccessControl` / `mergeIncludeWithAccessControl`
13
- * in `access-filter.ts`) a declared relation is scoped exactly like a
14
- * caller-named one, never a bypass.
12
+ * derived, or none at all) BEFORE it reaches the access-scoping pipeline
13
+ * (`buildAccessScopedInclude` in `access-filter.ts`) — a declared relation is
14
+ * scoped exactly like a caller-named one, never a bypass.
15
15
  *
16
16
  * The fold also tracks provenance: which relation keys, at which nesting
17
17
  * level, were added ONLY to satisfy a declaration (as opposed to being named
@@ -19,25 +19,44 @@ import { getRelatedListConfig } from './engine.js'
19
19
  * from the result after `resolveOutput` hooks have had a chance to read them
20
20
  * — a declared dependency is private plumbing, not an implicit `include`.
21
21
  *
22
- * Reach beyond one hop: a relation added here to satisfy a declaration is
23
- * added BARE (`true`), never with an explicit nested include of its own.
24
- * `buildIncludeWithAccessControl` already auto-expands a bare relation's own
25
- * readable-relationship subtree to `READ_INCLUDE_MAX_DEPTH` (pre-ADR-0026),
26
- * so a chain of declarations rides that existing expansion for free the
27
- * related list's own declared needs are already present among what gets
28
- * auto-included beneath it. This is also why a declaration-driven cycle
29
- * (e.g. `Order.total` needs `lineItems`, `LineItem.orderRef` needs `order`)
30
- * can't recurse without bound here: it flows through
31
- * `buildIncludeWithAccessControl`'s existing `visitedLists` cycle guard
32
- * rather than through any recursion of this module's own (see ADR-0026's
33
- * note that this guard's remaining job, after that ADR lands, is defending
34
- * exactly this fold).
22
+ * **Every relation this module reaches is folded recursively**, whether it
23
+ * was added purely to satisfy a declaration or is already present for another
24
+ * reason (caller/fragment-named, bare or not). Since ADR-0026 removed the
25
+ * auto-expansion that used to carry a chain of declarations "for free" —
26
+ * naming a relation now fetches its own columns and stopsa field computed
27
+ * one hop down would otherwise lose its own declared dependencies the moment
28
+ * its list is reached any way OTHER than an explicit nested caller `include`.
29
+ * Declarations fold in "at every level a field is computed" (ADR-0025), not
30
+ * only where the caller happened to write one out.
35
31
  *
36
- * This module only recurses into EXPLICIT nested includes the caller wrote
37
- * (narrowing what's fetched below a relation) those cut off the free
38
- * auto-expansion, so a nested list's own declared needs must be folded in
39
- * explicitly. An explicit caller include is always a finite literal, so this
40
- * recursion terminates on its own without a separate depth/cycle guard.
32
+ * A branch added purely by this fold (`declaredOnly.keys`) is stripped
33
+ * wholesale by `filterReadableFields` regardless of what's nested inside it,
34
+ * so its own nested declared-only keys need no individual tracking only a
35
+ * branch present for another reason (caller/fragment-named) needs the
36
+ * fine-grained `declaredOnly.nested` tracking, so a declaration folded
37
+ * beneath IT can be stripped without removing the caller's own data.
38
+ *
39
+ * **Cycle guard, re-pointed (ADR-0026).** A caller-supplied `include` is
40
+ * always a finite literal and cannot cycle. Recursively following declared
41
+ * dependencies across lists CAN — list A declaring `needs` on a relation to
42
+ * list B, whose own field declares `needs` back to A — so `visitedLists`
43
+ * carries the list names already on this fold's own path and stops rather
44
+ * than recursing without bound. `pnpm generate`'s `validateNeedsClosureDepth`
45
+ * (`needs-closure.ts`) is the primary backstop — a cyclic `needs` closure
46
+ * fails generation and should never reach this code at runtime — this guard
47
+ * is defense in depth, not the mechanism relied on for correctness.
48
+ *
49
+ * The guard applies to DECLARATION-ADDED edges only, which is exactly where
50
+ * the unbounded recursion can come from: a branch added by this fold carries
51
+ * no caller include of its own, so everything beneath it is declaration-added
52
+ * too, and each such edge either reaches a list not yet on the path or stops
53
+ * — bounding that suffix by the number of lists. An edge the request itself
54
+ * named is bounded by the request's own finite literal instead. Applying the
55
+ * guard to those as well would stop the fold at a path that merely revisits a
56
+ * list (`Post → author → posts`, or a self-referential `parent`), silently
57
+ * leaving the revisited list's own declared dependencies unsatisfied — the
58
+ * `undefined`-compute ADR-0025 exists to prevent, at every level a field is
59
+ * computed.
41
60
  */
42
61
 
43
62
  /** Which relation keys, at which nesting level, exist only to satisfy a `needs` declaration. */
@@ -67,16 +86,39 @@ function isRelationshipFieldConfig(
67
86
  )
68
87
  }
69
88
 
89
+ /** A plain object — excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
90
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
91
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
92
+ }
93
+
94
+ /** The explicit nested `include` on an include entry, if the entry is a structured object naming one. */
95
+ function getExplicitInclude(value: unknown): Record<string, unknown> | undefined {
96
+ if (!isPlainObject(value)) return undefined
97
+ const { include } = value
98
+ return isPlainObject(include) ? include : undefined
99
+ }
100
+
70
101
  /**
71
102
  * The deduped set of relation names declared via `needs` by fields on this
72
103
  * list that have a `resolveOutput` hook. A `needs` entry on a field without
73
104
  * one is inert — there is no hook to feed it to — so it contributes nothing
74
105
  * to fetch.
106
+ *
107
+ * `selectedFields`, when given, restricts the union to fields the read is
108
+ * actually going to return (ADR-0027) — a field a fragment did not select is
109
+ * never computed, so its declared relation is never fetched for it either.
110
+ * `undefined` means unrestricted: every field with a hook contributes,
111
+ * matching a bare or `include`-based read, which always returns every
112
+ * computed field on the list.
75
113
  */
76
- export function getDeclaredRelationNames(fieldConfigs: Record<string, FieldConfig>): string[] {
114
+ export function getDeclaredRelationNames(
115
+ fieldConfigs: Record<string, FieldConfig>,
116
+ selectedFields?: ReadonlySet<string>,
117
+ ): string[] {
77
118
  const names = new Set<string>()
78
- for (const fieldConfig of Object.values(fieldConfigs)) {
119
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
79
120
  if (!fieldConfig?.hooks?.resolveOutput) continue
121
+ if (selectedFields && !selectedFields.has(fieldName)) continue
80
122
  for (const name of fieldConfig.needs ?? []) {
81
123
  names.add(name)
82
124
  }
@@ -86,19 +128,36 @@ export function getDeclaredRelationNames(fieldConfigs: Record<string, FieldConfi
86
128
 
87
129
  /**
88
130
  * Fold this list's declared dependencies into `rawInclude`, recursing into
89
- * any EXPLICIT nested include the caller wrote so a related list's own
90
- * declared needs are satisfied too (see module doc comment).
131
+ * EVERY relation reached from here whether it was just added to satisfy a
132
+ * declaration or was already present for another reason — so that list's own
133
+ * declared dependencies are satisfied too, wherever it's reached (see module
134
+ * doc comment).
91
135
  *
92
136
  * Returns `rawInclude` itself (same reference) when there is nothing to
93
137
  * fold, so a list with no `needs` fields and no caller include stays on the
94
138
  * exact bare-read path (ADR-0024) — untouched, not merely equivalent.
139
+ *
140
+ * `listKey` seeds the cycle guard at the root of a read; recursive calls
141
+ * extend it with each related list reached along THIS fold's own path.
142
+ *
143
+ * `selection`, when given, is the fragment scope this level was reached
144
+ * under (ADR-0027) — only fields it names contribute their `needs` (see
145
+ * `getDeclaredRelationNames`). It is `undefined` for a bare/`include`-based
146
+ * read (unrestricted: every field's `needs` folds in, unchanged from before
147
+ * ADR-0027) and for any branch reached only to satisfy a declaration — a
148
+ * relation added purely by this fold has no fragment scope of its own, so
149
+ * its own list folds unrestricted, exactly as it did before selectivity
150
+ * existed.
95
151
  */
96
152
  export function foldDeclaredDependencies(
97
153
  rawInclude: Record<string, unknown> | undefined,
98
154
  fieldConfigs: Record<string, FieldConfig>,
99
155
  config: OpenSaasConfig,
156
+ listKey: string,
157
+ visitedLists: readonly string[] = [listKey],
158
+ selection?: FieldSelectionScope,
100
159
  ): { include: Record<string, unknown> | undefined; declaredOnly: DeclaredOnlyTree } {
101
- const declaredNames = getDeclaredRelationNames(fieldConfigs)
160
+ const declaredNames = getDeclaredRelationNames(fieldConfigs, selection?.fields)
102
161
 
103
162
  if (declaredNames.length === 0 && !rawInclude) {
104
163
  return { include: rawInclude, declaredOnly: emptyDeclaredOnlyTree() }
@@ -117,21 +176,47 @@ export function foldDeclaredDependencies(
117
176
  for (const [key, value] of Object.entries(merged)) {
118
177
  const fieldConfig = fieldConfigs[key]
119
178
  if (!isRelationshipFieldConfig(fieldConfig)) continue
120
- // A whole branch we just added is bare `true` — its own subtree auto-expands
121
- // (see module doc comment), so there is no explicit nested include to recurse into.
122
- if (declaredOnly.keys.has(key)) continue
123
-
124
- const entry = value as { include?: Record<string, unknown> } | boolean
125
- if (!entry || typeof entry !== 'object' || !entry.include) continue
126
179
 
127
180
  const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
128
181
  if (!relatedConfig) continue
182
+ // Defensive cycle guard (see module doc comment) — a DECLARATION-ADDED
183
+ // edge into a list already on this path stops here rather than recursing
184
+ // without bound. The value at `key` is left exactly as-is. The guard is
185
+ // deliberately not applied to an edge the request itself named: that
186
+ // recursion is bounded by the request's own finite literal and cannot
187
+ // loop, so stopping it would silently drop the folds beneath a request
188
+ // that merely revisits a list (e.g. `Post → author → posts`).
189
+ if (declaredOnly.keys.has(key) && visitedLists.includes(relatedConfig.listName)) continue
129
190
 
130
- const nested = foldDeclaredDependencies(entry.include, relatedConfig.listConfig.fields, config)
131
- if (nested.include !== entry.include) {
132
- merged[key] = { ...entry, include: nested.include }
191
+ // A branch added purely by the fold has no fragment scope of its own —
192
+ // it folds unrestricted, as before ADR-0027. A branch the request itself
193
+ // named (caller include or fragment) carries that name's own nested
194
+ // scope, if the fragment gave it one (a bare `true` selector leaves it
195
+ // `undefined` — also unrestricted, since the caller asked for
196
+ // "everything" there).
197
+ const nestedSelection = declaredOnly.keys.has(key) ? undefined : selection?.nested[key]
198
+
199
+ const explicitNested = getExplicitInclude(value)
200
+ const nested = foldDeclaredDependencies(
201
+ explicitNested,
202
+ relatedConfig.listConfig.fields,
203
+ config,
204
+ relatedConfig.listName,
205
+ [...visitedLists, relatedConfig.listName],
206
+ nestedSelection,
207
+ )
208
+
209
+ if (nested.include) {
210
+ merged[key] = {
211
+ ...(typeof value === 'object' && value ? value : {}),
212
+ include: nested.include,
213
+ }
133
214
  }
134
- if (!isDeclaredOnlyTreeEmpty(nested.declaredOnly)) {
215
+
216
+ // A whole branch added purely by the fold above is stripped wholesale by
217
+ // field-visibility regardless of what's nested inside it, so it needs no
218
+ // individual nested-key tracking of its own.
219
+ if (!declaredOnly.keys.has(key) && !isDeclaredOnlyTreeEmpty(nested.declaredOnly)) {
135
220
  declaredOnly.nested[key] = nested.declaredOnly
136
221
  }
137
222
  }
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Maximum nesting depth of relation `include`s that the Access Filter
3
- * (`buildIncludeWithAccessControl`) will auto-scope on a read.
2
+ * Maximum nesting depth of relation `include`s the Access Filter
3
+ * (`buildAccessScopedInclude`) will scope on a read.
4
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.
5
+ * Since ADR-0026 made the read pipeline caller-directed, this is a COST
6
+ * limit, not an access-control boundary: nothing walks the relationship
7
+ * graph unprompted anymore, so there is no unscoped tree to fail open on. A
8
+ * request naming a relation at or beyond this depth still throws
9
+ * `AccessScopeDepthExceededError` (ADR-0022, issue #830) — the engine
10
+ * declines to serve a tree this expensive, not because it cannot scope one.
10
11
  */
11
12
  export const READ_INCLUDE_MAX_DEPTH = 5
12
13
 
@@ -2,15 +2,19 @@ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
2
2
 
3
3
  /**
4
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.
5
+ * `READ_INCLUDE_MAX_DEPTH`. Deliberately distinct from `ValidationError`: this
6
+ * is not bad user input, it is the engine declining to serve a tree this
7
+ * expensive. Code that catches `ValidationError` to report form errors must
8
+ * not silently swallow this.
10
9
  *
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.
10
+ * Only an explicit request naming something at or past the cap triggers this
11
+ * a read that simply doesn't reach this deep never throws. Before
12
+ * ADR-0026 made the read pipeline caller-directed, this cap was the engine's
13
+ * last line of defense against returning a relation it could not prove was
14
+ * row/field scoped (ADR-0022, issue #830); a request naming anything at this
15
+ * depth is now scoped exactly like every other named relation; the cap
16
+ * exists solely to bound how deep a request may cost the engine to serve. See
17
+ * ADR-0026 and `docs/adr/0022-access-control-fails-closed-when-it-cannot-scope.md`.
14
18
  */
15
19
  export class AccessScopeDepthExceededError extends Error {
16
20
  public listKey: string
@@ -19,10 +23,10 @@ export class AccessScopeDepthExceededError extends Error {
19
23
 
20
24
  constructor(listKey: string, fieldKey: string, depth: number) {
21
25
  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
+ `Cannot include "${listKey}.${fieldKey}" at include depth ${depth}: this exceeds the read ` +
27
+ `pipeline's maximum include depth (${READ_INCLUDE_MAX_DEPTH}). This is a cost limit, not an ` +
28
+ `inability to scope the engine declines to serve a tree this deep rather than returning ` +
29
+ `it. Restructure the query to fetch this relation separately.`,
26
30
  )
27
31
  this.name = 'AccessScopeDepthExceededError'
28
32
  this.listKey = listKey