@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +121 -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/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +126 -0
- package/dist/config/types.d.ts.map +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/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +28 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/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/config/index.ts +2 -0
- package/src/config/types.ts +130 -0
- package/src/context/index.ts +52 -33
- package/src/fields/index.ts +35 -5
- package/src/index.ts +2 -0
- 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/field-types.test.ts +12 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tsconfig.tsbuildinfo +1 -1
package/src/query/index.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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
|
|
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
|
|
156
|
-
const {
|
|
155
|
+
// Test that buildAccessScopedInclude excludes the denied relationship
|
|
156
|
+
const { buildAccessScopedInclude } = await import('../src/access/index.js')
|
|
157
157
|
|
|
158
|
-
const include = await
|
|
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
|
|
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
|
|
322
|
-
const {
|
|
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
|
|
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
|
|
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
|
|
491
|
-
const {
|
|
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
|
|
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
|
+
})
|
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
|
|
@@ -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(
|
|
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([
|