@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +68 -0
- package/CLAUDE.md +21 -3
- package/dist/access/access-filter.d.ts +30 -118
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +70 -206
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +67 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/errors.d.ts +12 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +16 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +91 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +38 -27
- package/dist/context/index.js.map +1 -1
- package/dist/query/index.d.ts +29 -0
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +27 -0
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +84 -267
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +122 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/errors.ts +16 -12
- package/src/access/field-visibility.ts +99 -14
- package/src/access/index.ts +1 -7
- package/src/access/relationship-count.ts +1 -1
- package/src/context/index.ts +52 -33
- package/src/query/index.ts +53 -0
- package/src/query/relationship-options.ts +1 -1
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +27 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- 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
|
+
})
|
package/tests/context.test.ts
CHANGED
|
@@ -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(
|
|
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
|
|
300
|
-
//
|
|
301
|
-
//
|
|
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
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
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
|
})
|