@opensaas/stack-core 0.40.0 → 0.42.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 +65 -0
- package/CLAUDE.md +52 -0
- package/dist/access/access-filter.d.ts +88 -18
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +343 -36
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +759 -6
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +11 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +26 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +34 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +47 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -2
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +73 -23
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +7 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +9 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +70 -58
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +16 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +76 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +21 -3
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +23 -29
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1403 -159
- package/src/access/access-filter.ts +525 -47
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.ts +28 -0
- package/src/access/errors.ts +55 -0
- package/src/access/field-visibility.ts +105 -24
- package/src/access/index.ts +11 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +76 -50
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +120 -39
- package/src/context/nested-operations.ts +35 -11
- package/src/context/write-pipeline.ts +52 -32
- package/src/hooks/index.ts +22 -15
- package/tests/context.test.ts +193 -5
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/nested-access-and-hooks.test.ts +324 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/hooks/index.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { Hooks } from '../config/types.js'
|
|
2
2
|
import type { AccessContext } from '../access/types.js'
|
|
3
3
|
import type { FieldConfig } from '../config/types.js'
|
|
4
|
+
// #1176: resolveInput/validate/beforeOperation/afterOperation (list AND field
|
|
5
|
+
// level) receive the full secured `StackContext` — sudo()/withSession()/
|
|
6
|
+
// transaction() bound to the write's own transaction client — not the plain
|
|
7
|
+
// `AccessContext`. Transaction-boundary hooks (beforeTransaction/
|
|
8
|
+
// afterTransaction) are unaffected: they stay on `AccessContext`/the base
|
|
9
|
+
// client by ADR-0028 design.
|
|
10
|
+
import type { StackContext } from '../context/index.js'
|
|
4
11
|
import { validateWithZod } from '../validation/schema.js'
|
|
5
12
|
import { checkFieldAccess } from '../access/field-access.js'
|
|
6
13
|
|
|
@@ -42,7 +49,7 @@ export async function executeResolveInput<
|
|
|
42
49
|
inputData: TCreateInput
|
|
43
50
|
resolvedData: TCreateInput
|
|
44
51
|
item: undefined
|
|
45
|
-
context:
|
|
52
|
+
context: StackContext
|
|
46
53
|
}
|
|
47
54
|
| {
|
|
48
55
|
listKey: string
|
|
@@ -50,7 +57,7 @@ export async function executeResolveInput<
|
|
|
50
57
|
inputData: TUpdateInput
|
|
51
58
|
resolvedData: TUpdateInput
|
|
52
59
|
item: TOutput
|
|
53
|
-
context:
|
|
60
|
+
context: StackContext
|
|
54
61
|
},
|
|
55
62
|
): Promise<TCreateInput | TUpdateInput> {
|
|
56
63
|
if (!hooks?.resolveInput) {
|
|
@@ -75,7 +82,7 @@ export async function executeValidate<
|
|
|
75
82
|
inputData: TCreateInput
|
|
76
83
|
resolvedData: TCreateInput
|
|
77
84
|
item: undefined
|
|
78
|
-
context:
|
|
85
|
+
context: StackContext
|
|
79
86
|
}
|
|
80
87
|
| {
|
|
81
88
|
listKey: string
|
|
@@ -83,13 +90,13 @@ export async function executeValidate<
|
|
|
83
90
|
inputData: TUpdateInput
|
|
84
91
|
resolvedData: TUpdateInput
|
|
85
92
|
item: TOutput
|
|
86
|
-
context:
|
|
93
|
+
context: StackContext
|
|
87
94
|
}
|
|
88
95
|
| {
|
|
89
96
|
listKey: string
|
|
90
97
|
operation: 'delete'
|
|
91
98
|
item: TOutput
|
|
92
|
-
context:
|
|
99
|
+
context: StackContext
|
|
93
100
|
},
|
|
94
101
|
): Promise<void> {
|
|
95
102
|
const validateHook = hooks?.validate || hooks?.validateInput
|
|
@@ -131,7 +138,7 @@ export async function executeBeforeOperation<
|
|
|
131
138
|
operation: 'create'
|
|
132
139
|
inputData: TCreateInput
|
|
133
140
|
resolvedData: TCreateInput
|
|
134
|
-
context:
|
|
141
|
+
context: StackContext
|
|
135
142
|
}
|
|
136
143
|
| {
|
|
137
144
|
listKey: string
|
|
@@ -139,13 +146,13 @@ export async function executeBeforeOperation<
|
|
|
139
146
|
inputData: TUpdateInput
|
|
140
147
|
item: TOutput
|
|
141
148
|
resolvedData: TUpdateInput
|
|
142
|
-
context:
|
|
149
|
+
context: StackContext
|
|
143
150
|
}
|
|
144
151
|
| {
|
|
145
152
|
listKey: string
|
|
146
153
|
operation: 'delete'
|
|
147
154
|
item: TOutput
|
|
148
|
-
context:
|
|
155
|
+
context: StackContext
|
|
149
156
|
},
|
|
150
157
|
): Promise<void> {
|
|
151
158
|
if (!hooks?.beforeOperation) {
|
|
@@ -168,7 +175,7 @@ export async function executeAfterOperation<
|
|
|
168
175
|
inputData: TCreateInput
|
|
169
176
|
item: TOutput
|
|
170
177
|
resolvedData: TCreateInput
|
|
171
|
-
context:
|
|
178
|
+
context: StackContext
|
|
172
179
|
}
|
|
173
180
|
| {
|
|
174
181
|
listKey: string
|
|
@@ -177,13 +184,13 @@ export async function executeAfterOperation<
|
|
|
177
184
|
originalItem: TOutput
|
|
178
185
|
item: TOutput
|
|
179
186
|
resolvedData: TUpdateInput
|
|
180
|
-
context:
|
|
187
|
+
context: StackContext
|
|
181
188
|
}
|
|
182
189
|
| {
|
|
183
190
|
listKey: string
|
|
184
191
|
operation: 'delete'
|
|
185
192
|
originalItem: TOutput
|
|
186
|
-
context:
|
|
193
|
+
context: StackContext
|
|
187
194
|
},
|
|
188
195
|
): Promise<void> {
|
|
189
196
|
if (!hooks?.afterOperation) {
|
|
@@ -472,7 +479,7 @@ export async function executeFieldResolveInputHooks(
|
|
|
472
479
|
resolvedData: Record<string, any>,
|
|
473
480
|
fields: Record<string, FieldConfig>,
|
|
474
481
|
operation: 'create' | 'update',
|
|
475
|
-
context:
|
|
482
|
+
context: StackContext,
|
|
476
483
|
listKey: string,
|
|
477
484
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
478
485
|
item?: any,
|
|
@@ -575,7 +582,7 @@ export async function executeFieldValidateHooks(
|
|
|
575
582
|
resolvedData: Record<string, any> | undefined,
|
|
576
583
|
fields: Record<string, FieldConfig>,
|
|
577
584
|
operation: 'create' | 'update' | 'delete',
|
|
578
|
-
context:
|
|
585
|
+
context: StackContext,
|
|
579
586
|
listKey: string,
|
|
580
587
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
581
588
|
item?: any,
|
|
@@ -640,7 +647,7 @@ export async function executeFieldBeforeOperationHooks(
|
|
|
640
647
|
resolvedData: Record<string, any>,
|
|
641
648
|
fields: Record<string, FieldConfig>,
|
|
642
649
|
operation: 'create' | 'update' | 'delete',
|
|
643
|
-
context:
|
|
650
|
+
context: StackContext,
|
|
644
651
|
listKey: string,
|
|
645
652
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
646
653
|
item?: any,
|
|
@@ -687,7 +694,7 @@ export async function executeFieldAfterOperationHooks(
|
|
|
687
694
|
resolvedData: Record<string, unknown> | undefined,
|
|
688
695
|
fields: Record<string, FieldConfig>,
|
|
689
696
|
operation: 'create' | 'update' | 'delete',
|
|
690
|
-
context:
|
|
697
|
+
context: StackContext,
|
|
691
698
|
listKey: string,
|
|
692
699
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
693
700
|
originalItem?: any,
|
package/tests/context.test.ts
CHANGED
|
@@ -48,6 +48,7 @@ describe('getContext', () => {
|
|
|
48
48
|
fields: {
|
|
49
49
|
name: { type: 'text' },
|
|
50
50
|
email: { type: 'text', isIndexed: 'unique' },
|
|
51
|
+
posts: { type: 'relationship', ref: 'Post.author', many: true },
|
|
51
52
|
},
|
|
52
53
|
access: {
|
|
53
54
|
operation: {
|
|
@@ -2281,6 +2282,59 @@ describe('getContext', () => {
|
|
|
2281
2282
|
expect(call.include.posts.where).toEqual({ status: { equals: 'published' } })
|
|
2282
2283
|
})
|
|
2283
2284
|
|
|
2285
|
+
it('caller `_count` folds the relation access where into the select (#1087)', async () => {
|
|
2286
|
+
relPrisma.author.findMany.mockResolvedValue([
|
|
2287
|
+
{ id: 'a1', name: 'Jo', _count: { posts: 3 } },
|
|
2288
|
+
])
|
|
2289
|
+
|
|
2290
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2291
|
+
const result = await context.db.author.findMany({
|
|
2292
|
+
include: { _count: { select: { posts: true } } },
|
|
2293
|
+
})
|
|
2294
|
+
|
|
2295
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2296
|
+
expect(call.include._count).toEqual({
|
|
2297
|
+
select: { posts: { where: { status: { equals: 'published' } } } },
|
|
2298
|
+
})
|
|
2299
|
+
expect(result[0]._count).toEqual({ posts: 3 })
|
|
2300
|
+
})
|
|
2301
|
+
|
|
2302
|
+
it('caller `_count` on a fully-denied relation is omitted from the select and returns 0 (#1087)', async () => {
|
|
2303
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
2304
|
+
|
|
2305
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2306
|
+
const result = await context.db.author.findMany({
|
|
2307
|
+
include: { _count: { select: { posts: true, secrets: true } } },
|
|
2308
|
+
})
|
|
2309
|
+
|
|
2310
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2311
|
+
// The denied `Secret` list is never asked to be counted...
|
|
2312
|
+
expect(call.include._count).toEqual({
|
|
2313
|
+
select: { posts: { where: { status: { equals: 'published' } } } },
|
|
2314
|
+
})
|
|
2315
|
+
// ...but the caller still sees an explicit `0`, never an absent key —
|
|
2316
|
+
// a count is session-relative, and `0` is what "no visible rows" means.
|
|
2317
|
+
expect(result[0]._count).toEqual({ secrets: 0 })
|
|
2318
|
+
})
|
|
2319
|
+
|
|
2320
|
+
it('sudo `_count` is used as-is, unscoped (behaviour preserved)', async () => {
|
|
2321
|
+
relPrisma.author.findMany.mockResolvedValue([
|
|
2322
|
+
{ id: 'a1', name: 'Jo', _count: { posts: 3, secrets: 7 } },
|
|
2323
|
+
])
|
|
2324
|
+
|
|
2325
|
+
const context = await getContext(relConfig, relPrisma, null).sudo()
|
|
2326
|
+
const result = await context.db.author.findMany({
|
|
2327
|
+
include: { _count: { select: { posts: true, secrets: true } } },
|
|
2328
|
+
})
|
|
2329
|
+
|
|
2330
|
+
expect(relPrisma.author.findMany).toHaveBeenCalledWith(
|
|
2331
|
+
expect.objectContaining({
|
|
2332
|
+
include: { _count: { select: { posts: true, secrets: true } } },
|
|
2333
|
+
}),
|
|
2334
|
+
)
|
|
2335
|
+
expect(result[0]._count).toEqual({ posts: 3, secrets: 7 })
|
|
2336
|
+
})
|
|
2337
|
+
|
|
2284
2338
|
it('AND-combines a caller nested where with the relation access where', async () => {
|
|
2285
2339
|
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
2286
2340
|
|
|
@@ -2322,7 +2376,7 @@ describe('getContext', () => {
|
|
|
2322
2376
|
)
|
|
2323
2377
|
})
|
|
2324
2378
|
|
|
2325
|
-
it('query fragment path
|
|
2379
|
+
it('query fragment path carries the access filter, same as the include: path (#1088)', async () => {
|
|
2326
2380
|
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
2327
2381
|
|
|
2328
2382
|
const postsFragment = defineFragment<{ id: string; title: string }>()({
|
|
@@ -2338,10 +2392,144 @@ describe('getContext', () => {
|
|
|
2338
2392
|
await context.db.author.findMany({ query: fragment })
|
|
2339
2393
|
|
|
2340
2394
|
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2341
|
-
//
|
|
2342
|
-
//
|
|
2343
|
-
//
|
|
2344
|
-
|
|
2395
|
+
// The fragment-built include now runs through the same scoping walk as
|
|
2396
|
+
// an explicit caller `include`, so `posts` carries Post's access where
|
|
2397
|
+
// (matching the `include: { posts: true }` test above) instead of a
|
|
2398
|
+
// bare, unfiltered `true`.
|
|
2399
|
+
expect(call.include).toEqual({ posts: { where: { status: { equals: 'published' } } } })
|
|
2400
|
+
})
|
|
2401
|
+
|
|
2402
|
+
it('drops a relation whose query access is false when named in a query fragment', async () => {
|
|
2403
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
2404
|
+
|
|
2405
|
+
const secretsFragment = defineFragment<{ id: string; value: string }>()({
|
|
2406
|
+
value: true,
|
|
2407
|
+
} as const)
|
|
2408
|
+
const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
|
|
2409
|
+
id: true,
|
|
2410
|
+
name: true,
|
|
2411
|
+
secrets: secretsFragment,
|
|
2412
|
+
} as const)
|
|
2413
|
+
|
|
2414
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2415
|
+
await context.db.author.findMany({ query: fragment })
|
|
2416
|
+
|
|
2417
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
2418
|
+
// `Secret`'s query access is `() => false` — the denied relation is
|
|
2419
|
+
// dropped from the include entirely, same as the `include:` path.
|
|
2420
|
+
expect(call.include.secrets).toBeUndefined()
|
|
2421
|
+
})
|
|
2422
|
+
|
|
2423
|
+
it('a fragment nesting past the depth cap raises the same depth error a caller include does', async () => {
|
|
2424
|
+
const chainLength = READ_INCLUDE_MAX_DEPTH + 2
|
|
2425
|
+
const chainConfig: OpenSaasConfig = {
|
|
2426
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
2427
|
+
lists: {},
|
|
2428
|
+
}
|
|
2429
|
+
for (let i = 0; i < chainLength; i++) {
|
|
2430
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
2431
|
+
const fields: Record<string, any> = { name: { type: 'text' } }
|
|
2432
|
+
if (i < chainLength - 1) fields.next = { type: 'relationship', ref: `D${i + 1}.prev` }
|
|
2433
|
+
if (i > 0) fields.prev = { type: 'relationship', ref: `D${i - 1}.next` }
|
|
2434
|
+
chainConfig.lists[`D${i}`] = { fields, access: { operation: { query: () => true } } }
|
|
2435
|
+
}
|
|
2436
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2437
|
+
const chainPrisma: any = {}
|
|
2438
|
+
for (let i = 0; i < chainLength; i++) {
|
|
2439
|
+
chainPrisma[`d${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// A fragment selecting `next` recursively, `hops` levels deep.
|
|
2443
|
+
function nestedFragmentFields(hops: number): Record<string, unknown> {
|
|
2444
|
+
if (hops <= 0) return { name: true }
|
|
2445
|
+
return {
|
|
2446
|
+
name: true,
|
|
2447
|
+
next: defineFragment<Record<string, unknown>>()(nestedFragmentFields(hops - 1)),
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
const deepFragment = defineFragment<Record<string, unknown>>()(
|
|
2451
|
+
nestedFragmentFields(READ_INCLUDE_MAX_DEPTH + 1),
|
|
2452
|
+
)
|
|
2453
|
+
|
|
2454
|
+
const context = await getContext(chainConfig, chainPrisma, null)
|
|
2455
|
+
|
|
2456
|
+
await expect(context.db.d0.findMany({ query: deepFragment })).rejects.toThrow(
|
|
2457
|
+
AccessScopeDepthExceededError,
|
|
2458
|
+
)
|
|
2459
|
+
expect(chainPrisma.d0.findMany).not.toHaveBeenCalled()
|
|
2460
|
+
})
|
|
2461
|
+
|
|
2462
|
+
it('sudo query fragment reads stay unscoped (behaviour preserved)', async () => {
|
|
2463
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
2464
|
+
|
|
2465
|
+
const secretsFragment = defineFragment<{ id: string; value: string }>()({
|
|
2466
|
+
value: true,
|
|
2467
|
+
} as const)
|
|
2468
|
+
const fragment = defineFragment<{ id: string; name: string; secrets: unknown }>()({
|
|
2469
|
+
id: true,
|
|
2470
|
+
name: true,
|
|
2471
|
+
secrets: secretsFragment,
|
|
2472
|
+
} as const)
|
|
2473
|
+
|
|
2474
|
+
const context = await getContext(relConfig, relPrisma, null).sudo()
|
|
2475
|
+
await context.db.author.findMany({ query: fragment })
|
|
2476
|
+
|
|
2477
|
+
// Under sudo, the fragment-built include is used as-is: the denied
|
|
2478
|
+
// `Secret` relation is fetched unfiltered, matching sudo's existing
|
|
2479
|
+
// include: behaviour.
|
|
2480
|
+
expect(relPrisma.author.findMany).toHaveBeenCalledWith(
|
|
2481
|
+
expect.objectContaining({ include: { secrets: true } }),
|
|
2482
|
+
)
|
|
2483
|
+
})
|
|
2484
|
+
|
|
2485
|
+
it('a fragment read on a to-one relation nulls out a row its related list denies, matching include: (#974)', async () => {
|
|
2486
|
+
// `Comment.post` is a to-one relation onto `Post`, whose query access
|
|
2487
|
+
// is a row filter (`status: published`) rather than a plain boolean —
|
|
2488
|
+
// Prisma can't carry that as a `where` on a to-one include, so it's
|
|
2489
|
+
// resolved via the post-query existence check instead.
|
|
2490
|
+
const postFragment = defineFragment<{ id: string; title: string }>()({
|
|
2491
|
+
title: true,
|
|
2492
|
+
} as const)
|
|
2493
|
+
const fragment = defineFragment<{ id: string; body: string; post: unknown }>()({
|
|
2494
|
+
id: true,
|
|
2495
|
+
body: true,
|
|
2496
|
+
post: postFragment,
|
|
2497
|
+
} as const)
|
|
2498
|
+
|
|
2499
|
+
relPrisma.comment.findMany.mockResolvedValue([
|
|
2500
|
+
{ id: 'c1', body: 'hi', post: { id: 'p1', title: 'Draft' } },
|
|
2501
|
+
])
|
|
2502
|
+
// The batched existence check queries the raw Post model directly;
|
|
2503
|
+
// an empty result means `p1` does not satisfy Post's access filter.
|
|
2504
|
+
relPrisma.post.findMany.mockResolvedValue([])
|
|
2505
|
+
|
|
2506
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2507
|
+
const result = await context.db.comment.findMany({ query: fragment })
|
|
2508
|
+
|
|
2509
|
+
expect(result[0].post).toBeNull()
|
|
2510
|
+
})
|
|
2511
|
+
|
|
2512
|
+
it('a fragment read and an equivalent include: read produce the same access-scoped include', async () => {
|
|
2513
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
2514
|
+
|
|
2515
|
+
const postsFragment = defineFragment<{ id: string; title: string }>()({
|
|
2516
|
+
title: true,
|
|
2517
|
+
} as const)
|
|
2518
|
+
const fragment = defineFragment<{ id: string; name: string; posts: unknown }>()({
|
|
2519
|
+
id: true,
|
|
2520
|
+
name: true,
|
|
2521
|
+
posts: postsFragment,
|
|
2522
|
+
} as const)
|
|
2523
|
+
|
|
2524
|
+
const context = await getContext(relConfig, relPrisma, null)
|
|
2525
|
+
await context.db.author.findMany({ query: fragment })
|
|
2526
|
+
const fragmentInclude = relPrisma.author.findMany.mock.calls[0][0].include
|
|
2527
|
+
|
|
2528
|
+
relPrisma.author.findMany.mockClear()
|
|
2529
|
+
await context.db.author.findMany({ include: { posts: true } })
|
|
2530
|
+
const callerInclude = relPrisma.author.findMany.mock.calls[0][0].include
|
|
2531
|
+
|
|
2532
|
+
expect(fragmentInclude).toEqual(callerInclude)
|
|
2345
2533
|
})
|
|
2346
2534
|
|
|
2347
2535
|
// Core new guarantee introduced by #852 / ADR-0026: naming one relation
|