@opensaas/stack-core 0.37.0 → 0.39.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 (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  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 +62 -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/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. 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,42 @@ 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; the value at `key`
184
+ // is left exactly as-is.
185
+ if (declaredOnly.keys.has(key) && visitedLists.includes(relatedConfig.listName)) continue
129
186
 
130
- const nested = foldDeclaredDependencies(entry.include, relatedConfig.listConfig.fields, config)
131
- if (nested.include !== entry.include) {
132
- merged[key] = { ...entry, include: nested.include }
187
+ // A branch added purely by the fold has no fragment scope of its own —
188
+ // it folds unrestricted, as before ADR-0027. A branch the request itself
189
+ // named (caller include or fragment) carries that name's own nested
190
+ // scope, if the fragment gave it one (a bare `true` selector leaves it
191
+ // `undefined` — also unrestricted, since the caller asked for
192
+ // "everything" there).
193
+ const nestedSelection = declaredOnly.keys.has(key) ? undefined : selection?.nested[key]
194
+
195
+ const explicitNested = getExplicitInclude(value)
196
+ const nested = foldDeclaredDependencies(
197
+ explicitNested,
198
+ relatedConfig.listConfig.fields,
199
+ config,
200
+ relatedConfig.listName,
201
+ [...visitedLists, relatedConfig.listName],
202
+ nestedSelection,
203
+ )
204
+
205
+ if (nested.include) {
206
+ merged[key] = {
207
+ ...(typeof value === 'object' && value ? value : {}),
208
+ include: nested.include,
209
+ }
133
210
  }
134
- if (!isDeclaredOnlyTreeEmpty(nested.declaredOnly)) {
211
+
212
+ // Declaration-added branches need no nested tracking of their own (see
213
+ // module doc comment) — only caller/fragment-named branches do.
214
+ if (!declaredOnly.keys.has(key) && !isDeclaredOnlyTreeEmpty(nested.declaredOnly)) {
135
215
  declaredOnly.nested[key] = nested.declaredOnly
136
216
  }
137
217
  }
@@ -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
 
@@ -16,28 +16,14 @@ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
16
16
  * and the access-control glossary in `CONTEXT.md`.
17
17
  */
18
18
 
19
- /**
20
- * Check if access control result is a boolean
21
- */
22
19
  export function isBoolean(value: unknown): value is boolean {
23
20
  return typeof value === 'boolean'
24
21
  }
25
22
 
26
- /**
27
- * Check if access control result is a Prisma filter
28
- */
29
23
  export function isPrismaFilter(value: unknown): value is PrismaFilter {
30
24
  return typeof value === 'object' && value !== null && !Array.isArray(value)
31
25
  }
32
26
 
33
- /**
34
- * Parse a relationship ref and get the related list configuration
35
- * Relationship refs are in the format "ListName.fieldName"
36
- *
37
- * @param relationshipRef - The ref string (e.g., "Post.author")
38
- * @param config - The OpenSaas configuration
39
- * @returns The related list name and config, or null if not found
40
- */
41
27
  export function getRelatedListConfig(
42
28
  relationshipRef: string,
43
29
  config: OpenSaasConfig,
@@ -59,9 +45,6 @@ export function getRelatedListConfig(
59
45
  return { listName, listConfig }
60
46
  }
61
47
 
62
- /**
63
- * Execute an access control function
64
- */
65
48
  export async function checkAccess<T = Record<string, unknown>>(
66
49
  accessControl: AccessControl<T> | undefined,
67
50
  args: {
@@ -75,35 +58,27 @@ export async function checkAccess<T = Record<string, unknown>>(
75
58
  return false
76
59
  }
77
60
 
78
- // Execute the access control function
79
61
  const result = await accessControl(args)
80
62
 
81
63
  return result
82
64
  }
83
65
 
84
- /**
85
- * Merge user filter with access control filter
86
- */
87
66
  export function mergeFilters(
88
67
  userFilter: PrismaFilter | undefined,
89
68
  accessFilter: boolean | PrismaFilter,
90
69
  ): PrismaFilter | null {
91
- // If access is denied, return null
92
70
  if (accessFilter === false) {
93
71
  return null
94
72
  }
95
73
 
96
- // If access is fully granted, use user filter
97
74
  if (accessFilter === true) {
98
75
  return userFilter || {}
99
76
  }
100
77
 
101
- // Merge access filter with user filter
102
78
  if (!userFilter) {
103
79
  return accessFilter
104
80
  }
105
81
 
106
- // Combine filters with AND
107
82
  return {
108
83
  AND: [accessFilter, userFilter],
109
84
  }