@opensaas/stack-core 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +68 -0
  3. package/CLAUDE.md +21 -3
  4. package/dist/access/access-filter.d.ts +30 -118
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +70 -206
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +67 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/errors.d.ts +12 -8
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +16 -12
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-visibility.d.ts +2 -1
  27. package/dist/access/field-visibility.d.ts.map +1 -1
  28. package/dist/access/field-visibility.js +91 -17
  29. package/dist/access/field-visibility.js.map +1 -1
  30. package/dist/access/index.d.ts +1 -2
  31. package/dist/access/index.d.ts.map +1 -1
  32. package/dist/access/index.js +1 -1
  33. package/dist/access/index.js.map +1 -1
  34. package/dist/access/relationship-count.d.ts +1 -1
  35. package/dist/context/index.d.ts.map +1 -1
  36. package/dist/context/index.js +38 -27
  37. package/dist/context/index.js.map +1 -1
  38. package/dist/query/index.d.ts +29 -0
  39. package/dist/query/index.d.ts.map +1 -1
  40. package/dist/query/index.js +27 -0
  41. package/dist/query/index.js.map +1 -1
  42. package/dist/query/relationship-options.d.ts +1 -1
  43. package/dist/query/relationship-options.js +1 -1
  44. package/package.json +1 -1
  45. package/src/access/access-filter.test.ts +205 -275
  46. package/src/access/access-filter.ts +84 -267
  47. package/src/access/declared-dependencies.test.ts +277 -0
  48. package/src/access/declared-dependencies.ts +122 -37
  49. package/src/access/depth-limits.ts +8 -7
  50. package/src/access/errors.ts +16 -12
  51. package/src/access/field-visibility.ts +99 -14
  52. package/src/access/index.ts +1 -7
  53. package/src/access/relationship-count.ts +1 -1
  54. package/src/context/index.ts +52 -33
  55. package/src/query/index.ts +53 -0
  56. package/src/query/relationship-options.ts +1 -1
  57. package/tests/access-relationships.test.ts +18 -16
  58. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  59. package/tests/context.test.ts +27 -0
  60. package/tests/needs-declared-dependencies.test.ts +7 -4
  61. package/tests/resolve-chain.test.ts +11 -11
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -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
@@ -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([
@@ -331,7 +331,7 @@ describe('resolve chain — concurrent hook invocations are isolated (#844)', ()
331
331
  expect(observedLengths.sort()).toEqual([1, 1, 1])
332
332
  })
333
333
 
334
- it('an unrelated top-level read in flight alongside a hook still gets its full explicit include scoped', async () => {
334
+ it('an unrelated top-level read in flight alongside a hook still gets its own explicit include scoped', async () => {
335
335
  let releaseSlowHook: () => void = () => {}
336
336
 
337
337
  const config: OpenSaasConfig = {
@@ -389,21 +389,21 @@ describe('resolve chain — concurrent hook invocations are isolated (#844)', ()
389
389
  await new Promise((resolve) => setTimeout(resolve, 0))
390
390
 
391
391
  // While the slow hook is still in flight, issue a plain top-level read
392
- // that has nothing to do with it. A bare read fetches scalars only
393
- // (ADR-0024), so name `child` explicitlya caller include naming a
394
- // relation BARE (`true`, no nested include of its own) still picks up the
395
- // access-controlled include for whatever lies beneath it.
396
- const fastPromise = context.db.fast.findMany({ include: { child: true } })
392
+ // that has nothing to do with it, explicitly naming a nested path two
393
+ // levels deep (child grandchild) the "One hop" rule (ADR-0026) means
394
+ // reaching grandchild requires naming it, which this request does.
395
+ const fastPromise = context.db.fast.findMany({
396
+ include: { child: { include: { grandchild: true } } },
397
+ })
397
398
 
398
399
  await fastPromise
399
400
  releaseSlowHook()
400
401
  await slowPromise
401
402
 
402
- // The unrelated read's access-controlled scoping must still descend two
403
- // levels deep (child grandchild), not collapse to a bare `{ child: true }`
404
- // because it happened to run while a totally different read's hook was
405
- // active — `insideResolveOutput` must stay scoped to the hook's OWN
406
- // derived context, never leak into a concurrently-running, unrelated one.
403
+ // The unrelated read's access-controlled scoping must descend exactly as
404
+ // far as ITS OWN request named, unaffected by a totally different read's
405
+ // hook being in flight concurrently no shared, leaking context state
406
+ // between the two.
407
407
  expect(prisma.fast.findMany.mock.calls[0][0].include).toEqual({
408
408
  child: { include: { grandchild: true } },
409
409
  })