@opensaas/stack-core 0.39.2 → 0.40.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 +150 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.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 +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +76 -10
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +8 -5
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +76 -10
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +259 -12
- package/tests/field-types.test.ts +113 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +65 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { buildAccessScopedInclude } from './access-filter.js'
|
|
2
|
+
import { buildAccessScopedInclude, resolveToOneAccessVisibility } from './access-filter.js'
|
|
3
3
|
import { AccessScopeDepthExceededError } from './errors.js'
|
|
4
4
|
import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
|
|
5
5
|
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
@@ -22,6 +22,14 @@ import type { AccessContext } from './types.js'
|
|
|
22
22
|
* relation the request doesn't name never has its list's `query` access
|
|
23
23
|
* invoked, and naming a relation fetches its own columns and stops (the "One
|
|
24
24
|
* hop" rule) at every level, not just the root.
|
|
25
|
+
*
|
|
26
|
+
* The scenarios in this file build every chain relation as to-MANY
|
|
27
|
+
* (`rel(ref, true)`), so `where`-in-`include` keeps meaning what it always
|
|
28
|
+
* has here — the walk-depth/caller-directed logic under test is arity-
|
|
29
|
+
* agnostic. The to-one-specific behavior this function grew for issue #974
|
|
30
|
+
* (a `where` on a to-one include is not something Prisma accepts, so it's
|
|
31
|
+
* recorded in `toOneAccessFilters` instead and resolved post-query by
|
|
32
|
+
* `resolveToOneAccessVisibility`) has its own dedicated describe blocks below.
|
|
25
33
|
*/
|
|
26
34
|
|
|
27
35
|
// A relationship field pointing at another list.
|
|
@@ -71,10 +79,10 @@ function chainConfig(count: number): {
|
|
|
71
79
|
const listName = `L${i}`
|
|
72
80
|
const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
|
|
73
81
|
if (i < count - 1) {
|
|
74
|
-
fields.next = rel(`L${i + 1}.prev
|
|
82
|
+
fields.next = rel(`L${i + 1}.prev`, true)
|
|
75
83
|
}
|
|
76
84
|
if (i > 0) {
|
|
77
|
-
fields.prev = rel(`L${i - 1}.next
|
|
85
|
+
fields.prev = rel(`L${i - 1}.next`, true)
|
|
78
86
|
}
|
|
79
87
|
const filter = { ownerId: { equals: listName } }
|
|
80
88
|
const queryFn = vi.fn(i === 0 ? () => true : () => filter)
|
|
@@ -139,7 +147,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
139
147
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
140
148
|
} as any as OpenSaasConfig
|
|
141
149
|
|
|
142
|
-
const include = await buildAccessScopedInclude(
|
|
150
|
+
const { include } = await buildAccessScopedInclude(
|
|
143
151
|
{ b: true },
|
|
144
152
|
config.lists.A.fields,
|
|
145
153
|
{ session: null, context: makeContext() },
|
|
@@ -155,7 +163,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
155
163
|
it("fetches a named relation's own columns and stops — does not auto-expand its subtree (One hop)", async () => {
|
|
156
164
|
const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
|
|
157
165
|
|
|
158
|
-
const include = await buildAccessScopedInclude(
|
|
166
|
+
const { include } = await buildAccessScopedInclude(
|
|
159
167
|
{ next: true },
|
|
160
168
|
config.lists.L0.fields,
|
|
161
169
|
{ session: null, context: makeContext() },
|
|
@@ -175,7 +183,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
175
183
|
it('scopes a nested path at every level the request names it', async () => {
|
|
176
184
|
const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
|
|
177
185
|
|
|
178
|
-
const include = await buildAccessScopedInclude(
|
|
186
|
+
const { include } = await buildAccessScopedInclude(
|
|
179
187
|
{ next: { include: { next: { include: { next: true } } } } },
|
|
180
188
|
config.lists.L0.fields,
|
|
181
189
|
{ session: null, context: makeContext() },
|
|
@@ -215,7 +223,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
|
|
|
215
223
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
216
224
|
} as any as OpenSaasConfig
|
|
217
225
|
|
|
218
|
-
const include = await buildAccessScopedInclude(
|
|
226
|
+
const { include } = await buildAccessScopedInclude(
|
|
219
227
|
{ b: { include: { c: { include: { a: true } } } } },
|
|
220
228
|
config.lists.A.fields,
|
|
221
229
|
{ session: null, context: makeContext() },
|
|
@@ -253,7 +261,7 @@ describe('buildAccessScopedInclude — caller take on a to-many relation (issue
|
|
|
253
261
|
it('preserves the take and AND-combines it with the access where', async () => {
|
|
254
262
|
const config = scopedConfig()
|
|
255
263
|
|
|
256
|
-
const include = await buildAccessScopedInclude(
|
|
264
|
+
const { include } = await buildAccessScopedInclude(
|
|
257
265
|
{ posts: { take: 10 } },
|
|
258
266
|
config.lists.User.fields,
|
|
259
267
|
{ session: null, context: makeContext() },
|
|
@@ -276,7 +284,7 @@ describe('buildAccessScopedInclude — caller take on a to-many relation (issue
|
|
|
276
284
|
const config = scopedConfig()
|
|
277
285
|
config.lists.Post.access = { operation: { query: () => false } }
|
|
278
286
|
|
|
279
|
-
const include = await buildAccessScopedInclude(
|
|
287
|
+
const { include } = await buildAccessScopedInclude(
|
|
280
288
|
{ posts: { take: 10 } },
|
|
281
289
|
config.lists.User.fields,
|
|
282
290
|
{ session: null, context: makeContext() },
|
|
@@ -342,7 +350,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
342
350
|
// Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP - 1) = HOPS_AT_CAP hops total — right at the boundary.
|
|
343
351
|
const requested = { next: nestedInclude(HOPS_AT_CAP - 1) }
|
|
344
352
|
|
|
345
|
-
const include = await buildAccessScopedInclude(
|
|
353
|
+
const { include } = await buildAccessScopedInclude(
|
|
346
354
|
requested,
|
|
347
355
|
config.lists.L0.fields,
|
|
348
356
|
{ session: null, context: makeContext() },
|
|
@@ -373,7 +381,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
373
381
|
const chainLength = HOPS_AT_CAP + 3
|
|
374
382
|
const { config } = chainConfig(chainLength)
|
|
375
383
|
|
|
376
|
-
const include = await buildAccessScopedInclude(
|
|
384
|
+
const { include } = await buildAccessScopedInclude(
|
|
377
385
|
{},
|
|
378
386
|
config.lists.L0.fields,
|
|
379
387
|
{ session: null, context: makeContext() },
|
|
@@ -397,7 +405,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
397
405
|
|
|
398
406
|
// An arbitrary (non-declared-relationship) key passed through unchanged —
|
|
399
407
|
// access control does not govern keys it doesn't recognize as relationships.
|
|
400
|
-
const include = await buildAccessScopedInclude(
|
|
408
|
+
const { include } = await buildAccessScopedInclude(
|
|
401
409
|
{ someUnrelatedKey: true },
|
|
402
410
|
config.lists.Leaf.fields,
|
|
403
411
|
{ session: null, context: makeContext() },
|
|
@@ -407,3 +415,261 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
|
|
|
407
415
|
expect(include).toEqual({ someUnrelatedKey: true })
|
|
408
416
|
})
|
|
409
417
|
})
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Regression coverage for issue #974: Prisma accepts a nested `where` on an
|
|
421
|
+
* `include` entry only for a to-many relation — the same shape on a to-one
|
|
422
|
+
* relation raises `PrismaClientValidationError`. `buildAccessScopedInclude`
|
|
423
|
+
* must never attach one for a to-one relation, whatever its related list's
|
|
424
|
+
* `query` access resolves to; the scoping information goes into
|
|
425
|
+
* `toOneAccessFilters` instead.
|
|
426
|
+
*/
|
|
427
|
+
describe('buildAccessScopedInclude — to-one relations record a post-query filter, not `where` (issue #974)', () => {
|
|
428
|
+
function ownerConfig(queryAccess: unknown): OpenSaasConfig {
|
|
429
|
+
return {
|
|
430
|
+
db: { provider: 'sqlite' },
|
|
431
|
+
lists: {
|
|
432
|
+
Child: {
|
|
433
|
+
fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
|
|
434
|
+
access: { operation: { query: () => true } },
|
|
435
|
+
},
|
|
436
|
+
Owner: {
|
|
437
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
438
|
+
access: { operation: { query: queryAccess } },
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
442
|
+
} as any
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
it('records the access filter instead of attaching `where` when the related list scopes by a filter', async () => {
|
|
446
|
+
const config = ownerConfig(() => ({ id: { equals: 'u1' } }))
|
|
447
|
+
|
|
448
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
449
|
+
{ owner: true },
|
|
450
|
+
config.lists.Child.fields,
|
|
451
|
+
{ session: null, context: makeContext() },
|
|
452
|
+
config,
|
|
453
|
+
'Child',
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
// The Prisma-bound include never carries a `where` on this to-one key —
|
|
457
|
+
// Prisma would reject it.
|
|
458
|
+
expect(include).toEqual({ owner: true })
|
|
459
|
+
expect(toOneAccessFilters).toEqual({
|
|
460
|
+
filters: {
|
|
461
|
+
owner: { kind: 'scoped', relatedListName: 'Owner', accessWhere: { id: { equals: 'u1' } } },
|
|
462
|
+
},
|
|
463
|
+
nested: {},
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('leaves a to-one relation whose related list is fully open untouched — no filter recorded, no extra query later', async () => {
|
|
468
|
+
const config = ownerConfig(() => true)
|
|
469
|
+
|
|
470
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
471
|
+
{ owner: true },
|
|
472
|
+
config.lists.Child.fields,
|
|
473
|
+
{ session: null, context: makeContext() },
|
|
474
|
+
config,
|
|
475
|
+
'Child',
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
expect(include).toEqual({ owner: true })
|
|
479
|
+
expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
it('drops a to-one relation whose related list denies query access outright, and records the denial', async () => {
|
|
483
|
+
const config = ownerConfig(() => false)
|
|
484
|
+
|
|
485
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
486
|
+
{ owner: true },
|
|
487
|
+
config.lists.Child.fields,
|
|
488
|
+
{ session: null, context: makeContext() },
|
|
489
|
+
config,
|
|
490
|
+
'Child',
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
// Never asked of Prisma at all — same as before this fix.
|
|
494
|
+
expect(include).toEqual({})
|
|
495
|
+
expect(toOneAccessFilters).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('records a to-one filter nested inside another relation the caller explicitly included', async () => {
|
|
499
|
+
const config: OpenSaasConfig = {
|
|
500
|
+
db: { provider: 'sqlite' },
|
|
501
|
+
lists: {
|
|
502
|
+
GrandChild: {
|
|
503
|
+
fields: { name: { type: 'text' } as FieldConfig, child: rel('Child') },
|
|
504
|
+
access: { operation: { query: () => true } },
|
|
505
|
+
},
|
|
506
|
+
Child: {
|
|
507
|
+
fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
|
|
508
|
+
access: { operation: { query: () => true } },
|
|
509
|
+
},
|
|
510
|
+
Owner: {
|
|
511
|
+
fields: { name: { type: 'text' } as FieldConfig },
|
|
512
|
+
access: { operation: { query: () => ({ id: { equals: 'u1' } }) } },
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
|
|
516
|
+
} as any
|
|
517
|
+
|
|
518
|
+
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
519
|
+
{ child: { include: { owner: true } } },
|
|
520
|
+
config.lists.GrandChild.fields,
|
|
521
|
+
{ session: null, context: makeContext() },
|
|
522
|
+
config,
|
|
523
|
+
'GrandChild',
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
expect(include).toEqual({ child: { include: { owner: true } } })
|
|
527
|
+
expect(toOneAccessFilters).toEqual({
|
|
528
|
+
filters: {},
|
|
529
|
+
nested: {
|
|
530
|
+
child: {
|
|
531
|
+
filters: {
|
|
532
|
+
owner: {
|
|
533
|
+
kind: 'scoped',
|
|
534
|
+
relatedListName: 'Owner',
|
|
535
|
+
accessWhere: { id: { equals: 'u1' } },
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
nested: {},
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
describe('resolveToOneAccessVisibility (issue #974)', () => {
|
|
546
|
+
function makeVisibilityContext(findMany: ReturnType<typeof vi.fn>): AccessContext {
|
|
547
|
+
return {
|
|
548
|
+
session: null,
|
|
549
|
+
_isSudo: false,
|
|
550
|
+
_resolveOutputChain: [],
|
|
551
|
+
prisma: { owner: { findMany } },
|
|
552
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
553
|
+
} as any
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
it('passes a denied filter straight through with no query', async () => {
|
|
557
|
+
const findMany = vi.fn()
|
|
558
|
+
const context = makeVisibilityContext(findMany)
|
|
559
|
+
|
|
560
|
+
const resolved = await resolveToOneAccessVisibility(
|
|
561
|
+
[{ id: 'c1' }],
|
|
562
|
+
{ filters: { owner: { kind: 'denied' } }, nested: {} },
|
|
563
|
+
{ session: null, context },
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
expect(resolved).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
|
|
567
|
+
expect(findMany).not.toHaveBeenCalled()
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('skips the query when no row carries a value at the scoped key', async () => {
|
|
571
|
+
const findMany = vi.fn()
|
|
572
|
+
const context = makeVisibilityContext(findMany)
|
|
573
|
+
const tree = {
|
|
574
|
+
filters: {
|
|
575
|
+
owner: {
|
|
576
|
+
kind: 'scoped' as const,
|
|
577
|
+
relatedListName: 'Owner',
|
|
578
|
+
accessWhere: { id: { equals: 'u1' } },
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
nested: {},
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const resolved = await resolveToOneAccessVisibility([{ id: 'c1', owner: null }], tree, {
|
|
585
|
+
session: null,
|
|
586
|
+
context,
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
expect(resolved).toEqual({
|
|
590
|
+
filters: { owner: { kind: 'visible', ids: new Set() } },
|
|
591
|
+
nested: {},
|
|
592
|
+
})
|
|
593
|
+
expect(findMany).not.toHaveBeenCalled()
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('batches every row into ONE existence check, through the raw Prisma client, using the exact access filter', async () => {
|
|
597
|
+
const findMany = vi.fn().mockResolvedValue([{ id: 'u1' }, { id: 'u2' }])
|
|
598
|
+
const context = makeVisibilityContext(findMany)
|
|
599
|
+
const tree = {
|
|
600
|
+
filters: {
|
|
601
|
+
owner: {
|
|
602
|
+
kind: 'scoped' as const,
|
|
603
|
+
relatedListName: 'Owner',
|
|
604
|
+
accessWhere: { studioId: { equals: 's1' } },
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
nested: {},
|
|
608
|
+
}
|
|
609
|
+
const items = [
|
|
610
|
+
{ id: 'c1', owner: { id: 'u1' } },
|
|
611
|
+
{ id: 'c2', owner: { id: 'u2' } },
|
|
612
|
+
{ id: 'c3', owner: { id: 'u3' } }, // excluded by the mocked response below
|
|
613
|
+
{ id: 'c4', owner: null }, // contributes no id
|
|
614
|
+
]
|
|
615
|
+
|
|
616
|
+
const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
|
|
617
|
+
|
|
618
|
+
// One call, not one per row.
|
|
619
|
+
expect(findMany).toHaveBeenCalledTimes(1)
|
|
620
|
+
expect(findMany).toHaveBeenCalledWith({
|
|
621
|
+
where: { AND: [{ studioId: { equals: 's1' } }, { id: { in: ['u1', 'u2', 'u3'] } }] },
|
|
622
|
+
select: { id: true },
|
|
623
|
+
})
|
|
624
|
+
expect(resolved.filters.owner).toEqual({ kind: 'visible', ids: new Set(['u1', 'u2']) })
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('recurses into nested trees, flattening rows reached through a to-many hop into one batch', async () => {
|
|
628
|
+
const ownerFindMany = vi.fn().mockResolvedValue([{ id: 'u1' }])
|
|
629
|
+
const context: AccessContext = {
|
|
630
|
+
session: null,
|
|
631
|
+
_isSudo: false,
|
|
632
|
+
_resolveOutputChain: [],
|
|
633
|
+
prisma: { owner: { findMany: ownerFindMany } },
|
|
634
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
635
|
+
} as any
|
|
636
|
+
const tree = {
|
|
637
|
+
filters: {},
|
|
638
|
+
nested: {
|
|
639
|
+
children: {
|
|
640
|
+
filters: {
|
|
641
|
+
owner: {
|
|
642
|
+
kind: 'scoped' as const,
|
|
643
|
+
relatedListName: 'Owner',
|
|
644
|
+
accessWhere: { id: { equals: 'u1' } },
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
nested: {},
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
}
|
|
651
|
+
const items = [
|
|
652
|
+
{
|
|
653
|
+
id: 'p1',
|
|
654
|
+
children: [
|
|
655
|
+
{ id: 'c1', owner: { id: 'u1' } },
|
|
656
|
+
{ id: 'c2', owner: { id: 'u9' } },
|
|
657
|
+
],
|
|
658
|
+
},
|
|
659
|
+
{ id: 'p2', children: [{ id: 'c3', owner: { id: 'u1' } }] },
|
|
660
|
+
]
|
|
661
|
+
|
|
662
|
+
const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
|
|
663
|
+
|
|
664
|
+
// Every child across BOTH parents resolved in one batched call.
|
|
665
|
+
expect(ownerFindMany).toHaveBeenCalledTimes(1)
|
|
666
|
+
expect(ownerFindMany).toHaveBeenCalledWith({
|
|
667
|
+
where: { AND: [{ id: { equals: 'u1' } }, { id: { in: ['u1', 'u9'] } }] },
|
|
668
|
+
select: { id: true },
|
|
669
|
+
})
|
|
670
|
+
expect(resolved.nested.children.filters.owner).toEqual({
|
|
671
|
+
kind: 'visible',
|
|
672
|
+
ids: new Set(['u1']),
|
|
673
|
+
})
|
|
674
|
+
})
|
|
675
|
+
})
|