@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
@@ -345,6 +345,59 @@ export function buildInclude(fields: FieldSelection<unknown>): Record<string, un
345
345
  return hasIncludes ? include : undefined
346
346
  }
347
347
 
348
+ /**
349
+ * A snapshot of which field names a fragment selects at one nesting level,
350
+ * plus the same tree one level down for every relation selected via a nested
351
+ * Fragment/RelationSelector. `fields: undefined` means "unrestricted" — every
352
+ * field at this level is going to be returned, which is what a bare or
353
+ * `include`-based read means for the whole tree (only a `query` fragment ever
354
+ * produces a restricted scope, and only as deep as it names).
355
+ *
356
+ * Used to make computed-field evaluation (`filterReadableFields`) and
357
+ * declared-dependency folding (`foldDeclaredDependencies`) projection-aware
358
+ * (ADR-0027): a field not named by the scope at its level is never computed
359
+ * and its `needs` are never folded, because the read is never going to
360
+ * return it.
361
+ * @internal
362
+ */
363
+ export type FieldSelectionScope = {
364
+ readonly fields: ReadonlySet<string> | undefined
365
+ readonly nested: Readonly<Record<string, FieldSelectionScope>>
366
+ }
367
+
368
+ /**
369
+ * Build the `FieldSelectionScope` for one fragment's field selection,
370
+ * recursing into nested Fragment/RelationSelector entries the same way
371
+ * `buildInclude` does. A relation named with the bare `true` shorthand (no
372
+ * narrower nested Fragment) gets no entry in `nested`, so a level reached
373
+ * through it is treated as unrestricted — the caller asked for "everything"
374
+ * there and gave no narrower shape to restrict it with.
375
+ * @internal
376
+ */
377
+ export function buildFieldSelectionScope(fields: FieldSelection<unknown>): FieldSelectionScope {
378
+ const fieldNames = new Set(Object.keys(fields as Record<string, unknown>))
379
+ const nested: Record<string, FieldSelectionScope> = {}
380
+
381
+ for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
382
+ if (value === null || value === true || typeof value !== 'object') continue
383
+ const val = value as Record<string, unknown>
384
+
385
+ if (isFragment(val)) {
386
+ nested[key] = buildFieldSelectionScope(val._fields as FieldSelection<unknown>)
387
+ continue
388
+ }
389
+
390
+ if ('query' in val && isFragment(val.query)) {
391
+ nested[key] = buildFieldSelectionScope(
392
+ (val.query as Fragment<unknown, FieldSelection<unknown>>)
393
+ ._fields as FieldSelection<unknown>,
394
+ )
395
+ }
396
+ }
397
+
398
+ return { fields: fieldNames, nested }
399
+ }
400
+
348
401
  /**
349
402
  * Recursively pick only the fields requested by a fragment from a raw Prisma
350
403
  * result object. This ensures the runtime shape exactly matches the type
@@ -23,7 +23,7 @@ export interface RelationshipOptionsArgs {
23
23
  * editor — the read primitive behind the `relationshipOptions` serverAction
24
24
  * op. Selects only `id` and the resolved label field (via
25
25
  * {@link getLabelFieldName}), so the fragment carries no relation keys and
26
- * `buildIncludeWithAccessControl`'s depth-5 auto-include never runs.
26
+ * `buildAccessScopedInclude` never has anything to scope.
27
27
  *
28
28
  * Operation-level `query` access on `relatedListKey` still applies — a denied
29
29
  * list resolves to `[]` (via the underlying access-controlled `findMany`).
@@ -123,7 +123,7 @@ describe('Relationship Access Control', () => {
123
123
  expect(result.author?.name).toBe('John Doe')
124
124
  })
125
125
 
126
- it('should filter out single relationship when access denied (via buildIncludeWithAccessControl)', async () => {
126
+ it('should filter out single relationship when access denied (via buildAccessScopedInclude)', async () => {
127
127
  const config: OpenSaasConfig = {
128
128
  db: {
129
129
  provider: 'postgresql',
@@ -152,16 +152,18 @@ describe('Relationship Access Control', () => {
152
152
  },
153
153
  }
154
154
 
155
- // Test that buildIncludeWithAccessControl excludes the denied relationship
156
- const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
155
+ // Test that buildAccessScopedInclude excludes the denied relationship
156
+ const { buildAccessScopedInclude } = await import('../src/access/index.js')
157
157
 
158
- const include = await buildIncludeWithAccessControl(
158
+ const include = await buildAccessScopedInclude(
159
+ { author: true },
159
160
  config.lists.Post.fields,
160
161
  {
161
162
  session: null,
162
163
  context: mockContext,
163
164
  },
164
165
  config,
166
+ 'Post',
165
167
  )
166
168
 
167
169
  // When access is denied, the relationship should not be included
@@ -286,7 +288,7 @@ describe('Relationship Access Control', () => {
286
288
  expect(result.posts?.[1].title).toBe('Post 2')
287
289
  })
288
290
 
289
- it('should filter items in many relationships based on query access (via buildIncludeWithAccessControl)', async () => {
291
+ it('should filter items in many relationships based on query access (via buildAccessScopedInclude)', async () => {
290
292
  const config: OpenSaasConfig = {
291
293
  db: {
292
294
  provider: 'postgresql',
@@ -318,19 +320,19 @@ describe('Relationship Access Control', () => {
318
320
  },
319
321
  }
320
322
 
321
- // Test that buildIncludeWithAccessControl creates the right where clause
322
- const { buildIncludeWithAccessControl, toPrismaInclude } =
323
- await import('../src/access/index.js')
323
+ // Test that buildAccessScopedInclude creates the right where clause
324
+ const { buildAccessScopedInclude } = await import('../src/access/index.js')
324
325
 
325
- const result = await buildIncludeWithAccessControl(
326
+ const include = await buildAccessScopedInclude(
327
+ { posts: true },
326
328
  config.lists.User.fields,
327
329
  {
328
330
  session: null,
329
331
  context: mockContext,
330
332
  },
331
333
  config,
334
+ 'User',
332
335
  )
333
- const include = toPrismaInclude(result)
334
336
 
335
337
  // Should include posts with a where filter
336
338
  expect(include).toBeDefined()
@@ -452,7 +454,7 @@ describe('Relationship Access Control', () => {
452
454
  })
453
455
 
454
456
  describe('session-based access for relationships', () => {
455
- it('should apply session-based access to relationships (via buildIncludeWithAccessControl)', async () => {
457
+ it('should apply session-based access to relationships (via buildAccessScopedInclude)', async () => {
456
458
  const config: OpenSaasConfig = {
457
459
  db: {
458
460
  provider: 'postgresql',
@@ -487,19 +489,19 @@ describe('Relationship Access Control', () => {
487
489
  },
488
490
  }
489
491
 
490
- // Test that buildIncludeWithAccessControl creates session-based where clause
491
- const { buildIncludeWithAccessControl, toPrismaInclude } =
492
- await import('../src/access/index.js')
492
+ // Test that buildAccessScopedInclude creates session-based where clause
493
+ const { buildAccessScopedInclude } = await import('../src/access/index.js')
493
494
 
494
- const result = await buildIncludeWithAccessControl(
495
+ const include = await buildAccessScopedInclude(
496
+ { posts: true },
495
497
  config.lists.User.fields,
496
498
  {
497
499
  session: { userId: '1' },
498
500
  context: mockContext,
499
501
  },
500
502
  config,
503
+ 'User',
501
504
  )
502
- const include = toPrismaInclude(result)
503
505
 
504
506
  // Should include posts with session-based where filter
505
507
  expect(include).toBeDefined()
@@ -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
+ })
@@ -1427,6 +1427,33 @@ describe('getContext', () => {
1427
1427
  expect(call.include).toEqual({ posts: true })
1428
1428
  })
1429
1429
 
1430
+ // Core new guarantee introduced by #852 / ADR-0026: naming one relation
1431
+ // no longer walks (and access-checks) every other relationship of the
1432
+ // list. Before this fix, `include: { posts: true }` would ALSO evaluate
1433
+ // `Secret`'s query access (and, one hop further, `Comment`'s) even
1434
+ // though the caller never named `secrets` — wasted access calls the
1435
+ // caller never asked to pay for. Asserted directly on the access
1436
+ // function, not just on the resulting include shape.
1437
+ it('does not invoke query access on a relation the caller did not name (#852)', async () => {
1438
+ const postQuerySpy = vi.fn(() => ({ status: { equals: 'published' } }))
1439
+ const secretQuerySpy = vi.fn(() => false)
1440
+ const spiedConfig: OpenSaasConfig = {
1441
+ ...relConfig,
1442
+ lists: {
1443
+ ...relConfig.lists,
1444
+ Post: { ...relConfig.lists.Post, access: { operation: { query: postQuerySpy } } },
1445
+ Secret: { ...relConfig.lists.Secret, access: { operation: { query: secretQuerySpy } } },
1446
+ },
1447
+ }
1448
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
1449
+
1450
+ const context = await getContext(spiedConfig, relPrisma, null)
1451
+ await context.db.author.findMany({ include: { posts: true } })
1452
+
1453
+ expect(postQuerySpy).toHaveBeenCalledTimes(1)
1454
+ expect(secretQuerySpy).not.toHaveBeenCalled()
1455
+ })
1456
+
1430
1457
  // Regression for issue #830: a read issued from inside a `resolveOutput`
1431
1458
  // hook used to lose relation row scoping ENTIRELY — `buildIncludeWithAccessControl`
1432
1459
  // returned a whole-object `undefined` for the inner read (any
@@ -761,6 +761,18 @@ describe('Field Types', () => {
761
761
  expect(field.ref).toBe('Post.author')
762
762
  expect(field.many).toBe(true)
763
763
  })
764
+
765
+ test('accepts db.isNullable on a single relationship', () => {
766
+ const field = relationship({ ref: 'User.posts', db: { isNullable: false } })
767
+
768
+ expect(field.db?.isNullable).toBe(false)
769
+ })
770
+
771
+ test('throws error when db.isNullable is used with many: true', () => {
772
+ expect(() => {
773
+ relationship({ ref: 'Post.author', many: true, db: { isNullable: false } })
774
+ }).toThrow('db.isNullable can only be used on single relationships')
775
+ })
764
776
  })
765
777
  })
766
778
 
@@ -294,11 +294,14 @@ describe('a computed field declares the relations it needs (#850, ADR-0025)', ()
294
294
  expect(result).toHaveProperty('title')
295
295
  })
296
296
 
297
- it('a declaration cycle across two lists terminates via the existing relationship-graph cycle guard (ADR-0026 note)', async () => {
297
+ it("a declaration cycle across two lists terminates via the declaration fold's own cycle guard (ADR-0026 note)", async () => {
298
298
  // Order.total needs lineItems; LineItem.orderTitle needs order — a
299
- // two-list declaration cycle. This must not hang or crash: it rides the
300
- // SAME `visitedLists` cycle guard `buildIncludeWithAccessControl` already
301
- // uses for the relationship graph, not a separate mechanism.
299
+ // two-list declaration cycle. This must not hang or crash: it rides
300
+ // `foldDeclaredDependencies`'s own `visitedLists` cycle guard the same
301
+ // list-name-path mechanism the pre-ADR-0026 relationship-graph auto-walk
302
+ // used, re-pointed at the declaration fold now that nothing walks the
303
+ // relationship graph unprompted (defense in depth; the primary backstop
304
+ // is `validateNeedsClosureDepth` at generate time, see needs-closure.ts).
302
305
  const testConfig = await buildTestConfig({ withDeclarationCycle: true })
303
306
  const mockPrisma = createMockPrisma()
304
307
  mockPrisma.order.findMany.mockResolvedValue([