@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
@@ -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()
@@ -0,0 +1,418 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { getContext } from '../src/context/index.js'
3
+ import { config, list } from '../src/config/index.js'
4
+ import { text, integer, relationship, virtual } from '../src/fields/index.js'
5
+ import { defineFragment } from '../src/query/index.js'
6
+ import type { FieldConfig } from '../src/config/types.js'
7
+
8
+ /**
9
+ * Coverage for issue #855 / ADR-0027: a computed field — any field carrying a
10
+ * `resolveOutput` hook, virtual or not — is computed if and only if the read
11
+ * is going to return it, its declared relations (`needs`, ADR-0025) are
12
+ * fetched under exactly that same condition, and no computed field ever sees
13
+ * another computed field's resolved output.
14
+ *
15
+ * `tests/needs-declared-dependencies.test.ts` covers ADR-0025 itself (fetch
16
+ * folding, access scoping of a declared relation); this file covers the
17
+ * selectivity ADR-0027 adds on top of it.
18
+ */
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ function createMockPrisma(): any {
22
+ const model = () => ({
23
+ findFirst: vi.fn(),
24
+ findMany: vi.fn(),
25
+ })
26
+ return { order: model(), lineItem: model(), product: model() }
27
+ }
28
+
29
+ function buildTestConfig(spies: {
30
+ totalHook: ReturnType<typeof vi.fn>
31
+ doubleTotalHook: ReturnType<typeof vi.fn>
32
+ secretAccess: ReturnType<typeof vi.fn>
33
+ secretHook: ReturnType<typeof vi.fn>
34
+ unreachableAccess: ReturnType<typeof vi.fn>
35
+ unreachableHook: ReturnType<typeof vi.fn>
36
+ summaryHook: ReturnType<typeof vi.fn>
37
+ hooklessAccess: ReturnType<typeof vi.fn>
38
+ peekerHook: ReturnType<typeof vi.fn>
39
+ }) {
40
+ return config({
41
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
42
+ lists: {
43
+ Product: list({
44
+ fields: { name: text() },
45
+ access: { operation: { query: () => true } },
46
+ }),
47
+ LineItem: list({
48
+ fields: {
49
+ price: integer(),
50
+ order: relationship({ ref: 'Order.lineItems' }),
51
+ product: relationship({ ref: 'Product' }),
52
+ summary: virtual({
53
+ type: 'string',
54
+ needs: ['product'],
55
+ hooks: {
56
+ resolveOutput: (hookArgs: unknown) => {
57
+ spies.summaryHook(hookArgs)
58
+ const typedItem = (hookArgs as { item: { product?: { name?: string } | null } })
59
+ .item
60
+ return typedItem.product ? `${typedItem.product.name} x1` : 'unknown product x1'
61
+ },
62
+ },
63
+ }),
64
+ },
65
+ access: { operation: { query: () => true } },
66
+ }),
67
+ Order: list({
68
+ fields: {
69
+ title: text(),
70
+ lineItems: relationship({ ref: 'LineItem.order', many: true }),
71
+ total: virtual({
72
+ type: 'number',
73
+ needs: ['lineItems'],
74
+ hooks: {
75
+ resolveOutput: (hookArgs: unknown) => {
76
+ spies.totalHook(hookArgs)
77
+ const typedItem = (hookArgs as { item: { lineItems?: Array<{ price?: number }> } })
78
+ .item
79
+ return (typedItem.lineItems ?? []).reduce((sum, li) => sum + (li.price ?? 0), 0)
80
+ },
81
+ },
82
+ }),
83
+ // Declares the SAME relation as `total` — exercises "fetched once,
84
+ // even when only one of the two declaring fields is selected."
85
+ doubleTotal: virtual({
86
+ type: 'number',
87
+ needs: ['lineItems'],
88
+ hooks: {
89
+ resolveOutput: (hookArgs: unknown) => {
90
+ spies.doubleTotalHook(hookArgs)
91
+ const typedItem = (hookArgs as { item: { lineItems?: Array<{ price?: number }> } })
92
+ .item
93
+ return (typedItem.lineItems ?? []).reduce((sum, li) => sum + (li.price ?? 0), 0) * 2
94
+ },
95
+ },
96
+ }),
97
+ // A stored field with its own resolveOutput (a "computed field" per
98
+ // ADR-0027, not only virtual ones) whose access AND hook are spied
99
+ // on so a fragment that never selects it can be asserted to have
100
+ // invoked neither.
101
+ secret: text({
102
+ access: {
103
+ read: spies.secretAccess,
104
+ },
105
+ hooks: {
106
+ resolveOutput: (hookArgs: unknown) => {
107
+ spies.secretHook(hookArgs)
108
+ return `wrapped:${(hookArgs as { value: unknown }).value}`
109
+ },
110
+ },
111
+ }),
112
+ // A virtual field never selected by any fragment in these tests —
113
+ // stands in for "a field the read is never going to return."
114
+ unreachable: virtual({
115
+ type: 'string',
116
+ access: {
117
+ read: (accessArgs: unknown) => {
118
+ spies.unreachableAccess(accessArgs)
119
+ return true
120
+ },
121
+ },
122
+ hooks: {
123
+ resolveOutput: (hookArgs: unknown) => {
124
+ spies.unreachableHook(hookArgs)
125
+ return 'unreachable-value'
126
+ },
127
+ },
128
+ }),
129
+ // Reads a SIBLING computed field (`secret`) without declaring it —
130
+ // must see `undefined`, never `secret`'s resolved ("wrapped:...")
131
+ // value, and never its raw stored value either when `secret` is
132
+ // skipped by a fragment's own selection.
133
+ peeker: virtual({
134
+ type: 'string',
135
+ hooks: {
136
+ resolveOutput: (hookArgs: unknown) => {
137
+ spies.peekerHook(hookArgs)
138
+ const value = (hookArgs as { item: Record<string, unknown> }).item.secret
139
+ return value === undefined ? 'saw-nothing' : `saw:${String(value)}`
140
+ },
141
+ },
142
+ }),
143
+ // A hookless virtual field — can never produce a value on ANY
144
+ // read. Constructed as a raw FieldConfig (bypassing the `virtual()`
145
+ // builder, which throws without a `resolveOutput`) the way a
146
+ // third-party field package might legitimately shape one.
147
+ hooklessVirtual: {
148
+ type: 'text',
149
+ virtual: true,
150
+ access: {
151
+ read: (accessArgs: unknown) => {
152
+ spies.hooklessAccess(accessArgs)
153
+ return true
154
+ },
155
+ },
156
+ hooks: {},
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal raw field config for a test double
158
+ } as any as FieldConfig,
159
+ },
160
+ access: { operation: { query: () => true } },
161
+ }),
162
+ },
163
+ })
164
+ }
165
+
166
+ function makeSpies() {
167
+ return {
168
+ totalHook: vi.fn(),
169
+ doubleTotalHook: vi.fn(),
170
+ secretAccess: vi.fn(() => true),
171
+ secretHook: vi.fn(),
172
+ unreachableAccess: vi.fn(),
173
+ unreachableHook: vi.fn(),
174
+ summaryHook: vi.fn(),
175
+ hooklessAccess: vi.fn(),
176
+ peekerHook: vi.fn(),
177
+ }
178
+ }
179
+
180
+ describe('a computed field runs only when it is going to be returned (#855, ADR-0027)', () => {
181
+ it('a fragment that does not select a computed field runs neither its read access nor its hook, and does not fold its needs into the include', async () => {
182
+ const spies = makeSpies()
183
+ const testConfig = await buildTestConfig(spies)
184
+ const mockPrisma = createMockPrisma()
185
+ mockPrisma.order.findFirst.mockResolvedValue({ id: 'o1', title: 'Order 1' })
186
+
187
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
188
+ const fragment = defineFragment<any>()({ title: true } as const)
189
+ const context = getContext(testConfig, mockPrisma, null)
190
+ const result = await context.db.order.findUnique({ where: { id: 'o1' }, query: fragment })
191
+
192
+ // None of the unselected computed fields' declared relations were folded
193
+ // into the include — `lineItems` is only ever needed by `total`/
194
+ // `doubleTotal`, neither of which was selected, so there is nothing to
195
+ // fold and `include` stays exactly `undefined` (the bare-read shape).
196
+ const callArgs = mockPrisma.order.findFirst.mock.calls[0][0]
197
+ expect(callArgs.include).toBeUndefined()
198
+
199
+ expect(spies.totalHook).not.toHaveBeenCalled()
200
+ expect(spies.doubleTotalHook).not.toHaveBeenCalled()
201
+ expect(spies.secretAccess).not.toHaveBeenCalled()
202
+ expect(spies.secretHook).not.toHaveBeenCalled()
203
+ expect(spies.unreachableAccess).not.toHaveBeenCalled()
204
+ expect(spies.unreachableHook).not.toHaveBeenCalled()
205
+
206
+ expect(result).toEqual({ title: 'Order 1' })
207
+ })
208
+
209
+ it('a fragment that DOES select a computed field computes it correctly and fetches its declared relation, unchanged', async () => {
210
+ const spies = makeSpies()
211
+ const testConfig = await buildTestConfig(spies)
212
+ const mockPrisma = createMockPrisma()
213
+ mockPrisma.order.findFirst.mockResolvedValue({
214
+ id: 'o1',
215
+ title: 'Order 1',
216
+ lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }],
217
+ })
218
+
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ const fragment = defineFragment<any>()({ title: true, total: true } as const)
221
+ const context = getContext(testConfig, mockPrisma, null)
222
+ const result = await context.db.order.findUnique({ where: { id: 'o1' }, query: fragment })
223
+
224
+ const callArgs = mockPrisma.order.findFirst.mock.calls[0][0]
225
+ expect(callArgs.include).toMatchObject({ lineItems: expect.anything() })
226
+ expect(spies.totalHook).toHaveBeenCalledTimes(1)
227
+ expect(result?.total).toBe(10)
228
+ expect(result).not.toHaveProperty('lineItems')
229
+ })
230
+
231
+ it('two fields declaring the same relation, only one selected, still fetch that relation exactly once — and only the selected field computes', async () => {
232
+ const spies = makeSpies()
233
+ const testConfig = await buildTestConfig(spies)
234
+ const mockPrisma = createMockPrisma()
235
+ mockPrisma.order.findFirst.mockResolvedValue({
236
+ id: 'o1',
237
+ title: 'Order 1',
238
+ lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }],
239
+ })
240
+
241
+ // `doubleTotal` also needs `lineItems` but is NOT selected.
242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
243
+ const fragment = defineFragment<any>()({ title: true, total: true } as const)
244
+ const context = getContext(testConfig, mockPrisma, null)
245
+ const result = await context.db.order.findUnique({ where: { id: 'o1' }, query: fragment })
246
+
247
+ expect(mockPrisma.order.findFirst).toHaveBeenCalledTimes(1)
248
+ const callArgs = mockPrisma.order.findFirst.mock.calls[0][0]
249
+ // Exactly one `lineItems` entry in the include — folded once for `total`.
250
+ expect(Object.keys(callArgs.include)).toEqual(['lineItems'])
251
+
252
+ expect(result?.total).toBe(10)
253
+ expect(spies.totalHook).toHaveBeenCalledTimes(1)
254
+ expect(spies.doubleTotalHook).not.toHaveBeenCalled()
255
+ })
256
+
257
+ it('nested level: a nested fragment selecting a subset computes only that subset', async () => {
258
+ const spies = makeSpies()
259
+ const testConfig = await buildTestConfig(spies)
260
+ const mockPrisma = createMockPrisma()
261
+ mockPrisma.order.findFirst.mockResolvedValue({
262
+ id: 'o1',
263
+ title: 'Order 1',
264
+ lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }],
265
+ })
266
+
267
+ const lineItemFragment = defineFragment<{ price: number }>()({ price: true } as const)
268
+ const orderFragment = defineFragment<{ title: string; lineItems: unknown[] }>()({
269
+ title: true,
270
+ lineItems: lineItemFragment,
271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
272
+ } as any)
273
+
274
+ const context = getContext(testConfig, mockPrisma, null)
275
+ const result = await context.db.order.findUnique({
276
+ where: { id: 'o1' },
277
+ query: orderFragment,
278
+ })
279
+
280
+ // `summary` (needs `product`) was not selected inside the nested
281
+ // fragment, so neither its hook ran nor was `product` folded in beneath
282
+ // `lineItems`.
283
+ expect(spies.summaryHook).not.toHaveBeenCalled()
284
+ const callArgs = mockPrisma.order.findFirst.mock.calls[0][0]
285
+ expect(callArgs.include.lineItems).not.toMatchObject({
286
+ include: expect.objectContaining({ product: expect.anything() }),
287
+ })
288
+ expect(result?.lineItems?.[0]).toEqual({ price: 10 })
289
+ })
290
+
291
+ it('nested level: an include (not a fragment) still computes every computed field, unchanged', async () => {
292
+ const spies = makeSpies()
293
+ const testConfig = await buildTestConfig(spies)
294
+ const mockPrisma = createMockPrisma()
295
+ mockPrisma.order.findFirst.mockResolvedValue({
296
+ id: 'o1',
297
+ title: 'Order 1',
298
+ lineItems: [{ id: 'li1', price: 10, orderId: 'o1', product: { id: 'p1', name: 'Widget' } }],
299
+ })
300
+
301
+ const context = getContext(testConfig, mockPrisma, null)
302
+ const result = await context.db.order.findUnique({
303
+ where: { id: 'o1' },
304
+ include: { lineItems: { include: { product: true } } },
305
+ })
306
+
307
+ expect(spies.summaryHook).toHaveBeenCalledTimes(1)
308
+ expect(result?.lineItems?.[0].summary).toBe('Widget x1')
309
+ })
310
+
311
+ it("a hook's item never carries another computed field's resolved output, even on a bare read where both survive", async () => {
312
+ const spies = makeSpies()
313
+ const testConfig = await buildTestConfig(spies)
314
+ const mockPrisma = createMockPrisma()
315
+ mockPrisma.order.findFirst.mockResolvedValue({ id: 'o1', title: 'Order 1', secret: 'hunter2' })
316
+
317
+ const context = getContext(testConfig, mockPrisma, null)
318
+ // Bare read: every computed field computes, including `secret` (a stored
319
+ // field with its own resolveOutput) and `peeker` (a virtual field with no
320
+ // `needs` at all, reading `item.secret` without declaring it). `secret`
321
+ // is a plain stored scalar column, always fetched on any read (ADR-0024)
322
+ // regardless of declarations — only RELATIONS are conditionally fetched.
323
+ const result = await context.db.order.findUnique({ where: { id: 'o1' } })
324
+
325
+ // `secret`'s OWN resolveOutput wraps its stored value for the caller...
326
+ expect(result?.secret).toBe('wrapped:hunter2')
327
+ // ...but `peeker`, reading the same key from its own hook's `item`, sees
328
+ // the raw STORED column ('hunter2'), never `secret`'s resolved output
329
+ // ('wrapped:hunter2') — the "no computed field sees another's computed
330
+ // value" rule, proven by the two hooks disagreeing about the same key.
331
+ expect(result?.peeker).toBe('saw:hunter2')
332
+ })
333
+
334
+ it("a skipped field's key is absent from a sibling hook's item, never present holding its raw stored value", async () => {
335
+ const spies = makeSpies()
336
+ const testConfig = await buildTestConfig(spies)
337
+ const mockPrisma = createMockPrisma()
338
+ mockPrisma.order.findFirst.mockResolvedValue({ id: 'o1', title: 'Order 1', secret: 'hunter2' })
339
+
340
+ // `secret` is NOT selected; `peeker` is, and reads `item.secret`.
341
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
342
+ const fragment = defineFragment<any>()({ title: true, peeker: true } as const)
343
+ const context = getContext(testConfig, mockPrisma, null)
344
+ const result = await context.db.order.findUnique({ where: { id: 'o1' }, query: fragment })
345
+
346
+ // `secret`'s own access/hook never ran (it was never going to be returned).
347
+ expect(spies.secretAccess).not.toHaveBeenCalled()
348
+ expect(spies.secretHook).not.toHaveBeenCalled()
349
+ // `peeker` sees the key absent, never the raw pre-hook stored value.
350
+ expect(result?.peeker).toBe('saw-nothing')
351
+ expect(result).not.toHaveProperty('secret')
352
+ })
353
+
354
+ it('field-level read access still gates a field that IS selected: denied means absent and its hook does not run', async () => {
355
+ const spies = makeSpies()
356
+ spies.secretAccess.mockImplementation(() => false)
357
+ const testConfigDenied = await buildTestConfig(spies)
358
+ const mockPrisma = createMockPrisma()
359
+ mockPrisma.order.findFirst.mockResolvedValue({ id: 'o1', title: 'Order 1', secret: 'hunter2' })
360
+
361
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
+ const fragment = defineFragment<any>()({ title: true, secret: true } as const)
363
+ const context = getContext(testConfigDenied, mockPrisma, null)
364
+ const result = await context.db.order.findUnique({ where: { id: 'o1' }, query: fragment })
365
+
366
+ expect(spies.secretAccess).toHaveBeenCalled()
367
+ expect(spies.secretHook).not.toHaveBeenCalled()
368
+ expect(result?.secret).toBeUndefined()
369
+ })
370
+
371
+ it('a hookless virtual field does no work at all — its read access is never invoked on any read', async () => {
372
+ const spies = makeSpies()
373
+ const testConfig = await buildTestConfig(spies)
374
+ const mockPrisma = createMockPrisma()
375
+ mockPrisma.order.findFirst.mockResolvedValue({ id: 'o1', title: 'Order 1' })
376
+ mockPrisma.order.findMany.mockResolvedValue([{ id: 'o1', title: 'Order 1' }])
377
+
378
+ const context = getContext(testConfig, mockPrisma, null)
379
+
380
+ // Bare read.
381
+ await context.db.order.findUnique({ where: { id: 'o1' } })
382
+ expect(spies.hooklessAccess).not.toHaveBeenCalled()
383
+
384
+ // include-based read naming it explicitly.
385
+ await context.db.order.findUnique({ where: { id: 'o1' }, include: { hooklessVirtual: true } })
386
+ expect(spies.hooklessAccess).not.toHaveBeenCalled()
387
+
388
+ // fragment selecting it explicitly.
389
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
390
+ const fragment = defineFragment<any>()({ title: true, hooklessVirtual: true } as const)
391
+ await context.db.order.findUnique({ where: { id: 'o1' }, query: fragment })
392
+ expect(spies.hooklessAccess).not.toHaveBeenCalled()
393
+ })
394
+
395
+ it('include/bare reads still return every computed field on the list, unchanged', async () => {
396
+ const spies = makeSpies()
397
+ const testConfig = await buildTestConfig(spies)
398
+ const mockPrisma = createMockPrisma()
399
+ mockPrisma.order.findMany.mockResolvedValue([
400
+ {
401
+ id: 'o1',
402
+ title: 'Order 1',
403
+ secret: 'hunter2',
404
+ lineItems: [{ id: 'li1', price: 10, orderId: 'o1' }],
405
+ },
406
+ ])
407
+
408
+ const context = getContext(testConfig, mockPrisma, null)
409
+ const result = await context.db.order.findMany({})
410
+
411
+ expect(result[0].total).toBe(10)
412
+ expect(result[0].doubleTotal).toBe(20)
413
+ expect(result[0].secret).toBe('wrapped:hunter2')
414
+ expect(spies.totalHook).toHaveBeenCalledTimes(1)
415
+ expect(spies.doubleTotalHook).toHaveBeenCalledTimes(1)
416
+ expect(spies.secretHook).toHaveBeenCalledTimes(1)
417
+ })
418
+ })