@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.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +65 -0
  3. package/CLAUDE.md +52 -0
  4. package/dist/access/access-filter.d.ts +88 -18
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +343 -36
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +759 -6
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +11 -0
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +26 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/errors.d.ts +34 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +47 -0
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-visibility.d.ts +2 -2
  23. package/dist/access/field-visibility.d.ts.map +1 -1
  24. package/dist/access/field-visibility.js +73 -23
  25. package/dist/access/field-visibility.js.map +1 -1
  26. package/dist/access/index.d.ts +7 -3
  27. package/dist/access/index.d.ts.map +1 -1
  28. package/dist/access/index.js +9 -2
  29. package/dist/access/index.js.map +1 -1
  30. package/dist/access/multi-column-read-write.test.js.map +1 -1
  31. package/dist/access/query-validation.d.ts +31 -0
  32. package/dist/access/query-validation.d.ts.map +1 -1
  33. package/dist/access/query-validation.js +75 -16
  34. package/dist/access/query-validation.js.map +1 -1
  35. package/dist/access/relationship-count.d.ts +41 -3
  36. package/dist/access/relationship-count.d.ts.map +1 -1
  37. package/dist/access/relationship-count.js +44 -7
  38. package/dist/access/relationship-count.js.map +1 -1
  39. package/dist/access/relationship-count.test.js +62 -0
  40. package/dist/access/relationship-count.test.js.map +1 -1
  41. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  42. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  43. package/dist/access/synthetic-include-read.test.js +121 -0
  44. package/dist/access/synthetic-include-read.test.js.map +1 -0
  45. package/dist/config/types.d.ts +70 -58
  46. package/dist/config/types.d.ts.map +1 -1
  47. package/dist/context/hook-pipeline.d.ts +2 -2
  48. package/dist/context/hook-pipeline.d.ts.map +1 -1
  49. package/dist/context/hook-pipeline.js.map +1 -1
  50. package/dist/context/index.d.ts +16 -1
  51. package/dist/context/index.d.ts.map +1 -1
  52. package/dist/context/index.js +76 -26
  53. package/dist/context/index.js.map +1 -1
  54. package/dist/context/nested-operations.d.ts +2 -2
  55. package/dist/context/nested-operations.d.ts.map +1 -1
  56. package/dist/context/nested-operations.js +21 -3
  57. package/dist/context/nested-operations.js.map +1 -1
  58. package/dist/context/write-pipeline.d.ts.map +1 -1
  59. package/dist/context/write-pipeline.js +23 -29
  60. package/dist/context/write-pipeline.js.map +1 -1
  61. package/dist/hooks/index.d.ts +16 -15
  62. package/dist/hooks/index.d.ts.map +1 -1
  63. package/dist/hooks/index.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/access/access-filter.test.ts +1403 -159
  66. package/src/access/access-filter.ts +525 -47
  67. package/src/access/denied-relation-visibility.test.ts +173 -0
  68. package/src/access/engine.ts +28 -0
  69. package/src/access/errors.ts +55 -0
  70. package/src/access/field-visibility.ts +105 -24
  71. package/src/access/index.ts +11 -0
  72. package/src/access/multi-column-read-write.test.ts +3 -2
  73. package/src/access/query-validation.ts +136 -9
  74. package/src/access/relationship-count.test.ts +75 -0
  75. package/src/access/relationship-count.ts +53 -10
  76. package/src/access/synthetic-include-read.test.ts +179 -0
  77. package/src/config/types.ts +76 -50
  78. package/src/context/hook-pipeline.ts +4 -2
  79. package/src/context/index.ts +120 -39
  80. package/src/context/nested-operations.ts +35 -11
  81. package/src/context/write-pipeline.ts +52 -32
  82. package/src/hooks/index.ts +22 -15
  83. package/tests/context.test.ts +193 -5
  84. package/tests/hook-context-secured.test.ts +424 -0
  85. package/tests/nested-access-and-hooks.test.ts +324 -0
  86. package/tsconfig.tsbuildinfo +1 -1
@@ -1,7 +1,13 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
2
  import { buildAccessScopedInclude, resolveToOneAccessVisibility } from './access-filter.js'
3
- import { AccessScopeDepthExceededError } from './errors.js'
3
+ import {
4
+ AccessScopeDepthExceededError,
5
+ RelationFilterAccessDeniedError,
6
+ UndeclaredCountKeyError,
7
+ UndeclaredIncludeKeyError,
8
+ } from './errors.js'
4
9
  import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
10
+ import { ValidationError } from '../hooks/index.js'
5
11
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
6
12
  import type { AccessContext } from './types.js'
7
13
 
@@ -391,7 +397,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
391
397
  expect(include).toEqual({})
392
398
  })
393
399
 
394
- it('a list with no relationships passes an unrelated requested key through unchanged', async () => {
400
+ it('a declared non-relationship key on a list with no relationships passes through unchanged', async () => {
395
401
  const config = {
396
402
  db: { provider: 'sqlite' },
397
403
  lists: {
@@ -403,243 +409,645 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
403
409
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
404
410
  } as any as OpenSaasConfig
405
411
 
406
- // An arbitrary (non-declared-relationship) key passed through unchanged
407
- // access control does not govern keys it doesn't recognize as relationships.
412
+ // A declared field that isn't a relationship access control does not
413
+ // govern it (e.g. a virtual field named in `include`, stripped later by
414
+ // `stripVirtualFieldsFromInclude`).
408
415
  const { include } = await buildAccessScopedInclude(
409
- { someUnrelatedKey: true },
416
+ { name: true },
410
417
  config.lists.Leaf.fields,
411
418
  { session: null, context: makeContext() },
412
419
  config,
413
420
  'Leaf',
414
421
  )
415
- expect(include).toEqual({ someUnrelatedKey: true })
422
+ expect(include).toEqual({ name: true })
423
+ })
424
+
425
+ it('rejects a key that is neither declared nor a synthetic back-relation (#1082)', async () => {
426
+ const config = {
427
+ db: { provider: 'sqlite' },
428
+ lists: {
429
+ Leaf: {
430
+ fields: { name: { type: 'text' } as FieldConfig },
431
+ access: { operation: { query: () => true } },
432
+ },
433
+ },
434
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
435
+ } as any as OpenSaasConfig
436
+
437
+ await expect(
438
+ buildAccessScopedInclude(
439
+ { someUnrelatedKey: true },
440
+ config.lists.Leaf.fields,
441
+ { session: null, context: makeContext() },
442
+ config,
443
+ 'Leaf',
444
+ ),
445
+ ).rejects.toThrow(UndeclaredIncludeKeyError)
446
+ })
447
+
448
+ it('a bare `_count: true` on a list with no to-many relations yields no `_count` key at all (#1087)', async () => {
449
+ const config = {
450
+ db: { provider: 'sqlite' },
451
+ lists: {
452
+ Leaf: {
453
+ fields: { name: { type: 'text' } as FieldConfig },
454
+ access: { operation: { query: () => true } },
455
+ },
456
+ },
457
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
458
+ } as any as OpenSaasConfig
459
+
460
+ const { include } = await buildAccessScopedInclude(
461
+ { _count: true },
462
+ config.lists.Leaf.fields,
463
+ { session: null, context: makeContext() },
464
+ config,
465
+ 'Leaf',
466
+ )
467
+ expect(include).toEqual({})
416
468
  })
417
469
  })
418
470
 
419
471
  /**
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.
472
+ * Regression coverage for issue #1087: a caller-supplied `_count` in
473
+ * `include` used to reach Prisma unscoped (#1082's "Out of scope" the one
474
+ * key `buildAccessScopedInclude`'s own denial rule allowlisted through rather
475
+ * than rejecting or scoping). These tests pin the fix: each named relation's
476
+ * `_count.select` entry is scoped by that relation's own `query` access,
477
+ * exactly like the rest of this module scopes `include`, and a denied
478
+ * relation is recorded in `countDenials` for `filterReadableFields` to inject
479
+ * `0` for post-query (Prisma cannot be asked for a guaranteed `0`).
426
480
  */
427
- describe('buildAccessScopedInclude — to-one relations record a post-query filter, not `where` (issue #974)', () => {
428
- function ownerConfig(queryAccess: unknown): OpenSaasConfig {
481
+ describe('buildAccessScopedInclude — `_count` is scoped like any other relation (issue #1087)', () => {
482
+ function countConfig(postQuery: unknown): OpenSaasConfig {
429
483
  return {
430
484
  db: { provider: 'sqlite' },
431
485
  lists: {
432
- Child: {
433
- fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
486
+ User: {
487
+ fields: {
488
+ name: { type: 'text' } as FieldConfig,
489
+ posts: rel('Post.author', true),
490
+ secrets: rel('Secret.owner', true),
491
+ },
434
492
  access: { operation: { query: () => true } },
435
493
  },
436
- Owner: {
437
- fields: { name: { type: 'text' } as FieldConfig },
438
- access: { operation: { query: queryAccess } },
494
+ Post: {
495
+ fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
496
+ access: { operation: { query: postQuery } },
497
+ },
498
+ Secret: {
499
+ fields: { body: { type: 'text' } as FieldConfig, owner: rel('User.secrets') },
500
+ access: { operation: { query: () => false } },
439
501
  },
440
502
  },
441
503
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
442
504
  } as any
443
505
  }
444
506
 
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' } }))
507
+ it('a fully-readable relation counts unchanged', async () => {
508
+ const config = countConfig(() => true)
447
509
 
448
- const { include, toOneAccessFilters } = await buildAccessScopedInclude(
449
- { owner: true },
450
- config.lists.Child.fields,
510
+ const { include, countDenials } = await buildAccessScopedInclude(
511
+ { _count: { select: { posts: true } } },
512
+ config.lists.User.fields,
451
513
  { session: null, context: makeContext() },
452
514
  config,
453
- 'Child',
515
+ 'User',
454
516
  )
455
517
 
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' } } },
518
+ expect(include).toEqual({ _count: { select: { posts: true } } })
519
+ expect(countDenials.keys.size).toBe(0)
520
+ })
521
+
522
+ it('folds the related list row filter into the count select', async () => {
523
+ const config = countConfig(() => ({ published: { equals: true } }))
524
+
525
+ const { include } = await buildAccessScopedInclude(
526
+ { _count: { select: { posts: true } } },
527
+ config.lists.User.fields,
528
+ { session: null, context: makeContext() },
529
+ config,
530
+ 'User',
531
+ )
532
+
533
+ expect(include).toEqual({
534
+ _count: { select: { posts: { where: { published: { equals: true } } } } },
535
+ })
536
+ })
537
+
538
+ it('AND-combines a caller-supplied nested `where` with the access filter, never replacing it', async () => {
539
+ const config = countConfig(() => ({ published: { equals: true } }))
540
+
541
+ const { include } = await buildAccessScopedInclude(
542
+ { _count: { select: { posts: { where: { title: { contains: 'foo' } } } } } },
543
+ config.lists.User.fields,
544
+ { session: null, context: makeContext() },
545
+ config,
546
+ 'User',
547
+ )
548
+
549
+ expect(include).toEqual({
550
+ _count: {
551
+ select: {
552
+ posts: {
553
+ where: {
554
+ AND: [{ published: { equals: true } }, { title: { contains: 'foo' } }],
555
+ },
556
+ },
557
+ },
462
558
  },
463
- nested: {},
464
559
  })
465
560
  })
466
561
 
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)
562
+ it('omits a fully-denied relation from the select and records it in `countDenials`', async () => {
563
+ const config = countConfig(() => true)
469
564
 
470
- const { include, toOneAccessFilters } = await buildAccessScopedInclude(
471
- { owner: true },
472
- config.lists.Child.fields,
565
+ const { include, countDenials } = await buildAccessScopedInclude(
566
+ { _count: { select: { posts: true, secrets: true } } },
567
+ config.lists.User.fields,
473
568
  { session: null, context: makeContext() },
474
569
  config,
475
- 'Child',
570
+ 'User',
476
571
  )
477
572
 
478
- expect(include).toEqual({ owner: true })
479
- expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
573
+ expect(include).toEqual({ _count: { select: { posts: true } } })
574
+ expect(countDenials.keys).toEqual(new Set(['secrets']))
480
575
  })
481
576
 
482
- it('drops a to-one relation whose related list denies query access outright, and records the denial', async () => {
483
- const config = ownerConfig(() => false)
577
+ it('a wholly-denied `_count.select` omits the `_count` key entirely, still recording the denial', async () => {
578
+ const config = countConfig(() => true)
484
579
 
485
- const { include, toOneAccessFilters } = await buildAccessScopedInclude(
486
- { owner: true },
487
- config.lists.Child.fields,
580
+ const { include, countDenials } = await buildAccessScopedInclude(
581
+ { _count: { select: { secrets: true } } },
582
+ config.lists.User.fields,
488
583
  { session: null, context: makeContext() },
489
584
  config,
490
- 'Child',
585
+ 'User',
491
586
  )
492
587
 
493
- // Never asked of Prisma at all — same as before this fix.
494
588
  expect(include).toEqual({})
495
- expect(toOneAccessFilters).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
589
+ expect(countDenials.keys).toEqual(new Set(['secrets']))
496
590
  })
497
591
 
498
- it('records a to-one filter nested inside another relation the caller explicitly included', async () => {
592
+ it('a denied relation resolves to 0 without validating its caller `where` — no field-name/read-access oracle', async () => {
593
+ // A caller who cannot read a single `Secret` row supplies a nonsense
594
+ // `where` naming a field that doesn't exist. If denial were checked
595
+ // AFTER `where` validation, this would throw a `ValidationError` naming
596
+ // "Secret"'s fields — letting a caller probe a fully denied list's
597
+ // schema. Denial must win first: the count is 0 regardless of `where`.
598
+ const config = countConfig(() => true)
599
+
600
+ const { include, countDenials } = await buildAccessScopedInclude(
601
+ { _count: { select: { secrets: { where: { bogusField: { equals: 1 } } } } } },
602
+ config.lists.User.fields,
603
+ { session: null, context: makeContext() },
604
+ config,
605
+ 'User',
606
+ )
607
+
608
+ expect(include).toEqual({})
609
+ expect(countDenials.keys).toEqual(new Set(['secrets']))
610
+ })
611
+
612
+ it('rejects a `_count.select` key that is not a countable to-many (undeclared)', async () => {
613
+ const config = countConfig(() => true)
614
+
615
+ await expect(
616
+ buildAccessScopedInclude(
617
+ { _count: { select: { nonsense: true } } },
618
+ config.lists.User.fields,
619
+ { session: null, context: makeContext() },
620
+ config,
621
+ 'User',
622
+ ),
623
+ ).rejects.toThrow(UndeclaredCountKeyError)
624
+ })
625
+
626
+ it('rejects a `_count.select` key naming a scalar field', async () => {
627
+ const config = countConfig(() => true)
628
+
629
+ await expect(
630
+ buildAccessScopedInclude(
631
+ { _count: { select: { name: true } } },
632
+ config.lists.User.fields,
633
+ { session: null, context: makeContext() },
634
+ config,
635
+ 'User',
636
+ ),
637
+ ).rejects.toThrow(UndeclaredCountKeyError)
638
+ })
639
+
640
+ it('rejects a `_count.select` key naming a to-one relationship', async () => {
641
+ const config = countConfig(() => true)
642
+
643
+ await expect(
644
+ buildAccessScopedInclude(
645
+ { _count: { select: { author: true } } },
646
+ config.lists.Post.fields,
647
+ { session: null, context: makeContext() },
648
+ config,
649
+ 'Post',
650
+ ),
651
+ ).rejects.toThrow(UndeclaredCountKeyError)
652
+ })
653
+
654
+ // Issue #1111: a relationship hidden via field-level `read` access must not
655
+ // leak its true count through `_count`, even though the related list's rows
656
+ // are otherwise fully readable — the same denial an ordinary `include` of
657
+ // that field already gets from `filterReadableFields`'s `checkFieldAccess`
658
+ // call.
659
+ it('omits a relation whose field-level `read` access denies it, even though the related list is fully readable', async () => {
499
660
  const config: OpenSaasConfig = {
500
661
  db: { provider: 'sqlite' },
501
662
  lists: {
502
- GrandChild: {
503
- fields: { name: { type: 'text' } as FieldConfig, child: rel('Child') },
663
+ User: {
664
+ fields: {
665
+ name: { type: 'text' } as FieldConfig,
666
+ posts: {
667
+ type: 'relationship',
668
+ ref: 'Post.author',
669
+ many: true,
670
+ access: { read: () => false },
671
+ } as FieldConfig,
672
+ },
504
673
  access: { operation: { query: () => true } },
505
674
  },
506
- Child: {
507
- fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
675
+ Post: {
676
+ fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
508
677
  access: { operation: { query: () => true } },
509
678
  },
510
- Owner: {
511
- fields: { name: { type: 'text' } as FieldConfig },
512
- access: { operation: { query: () => ({ id: { equals: 'u1' } }) } },
513
- },
514
679
  },
515
680
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
516
681
  } as any
517
682
 
518
- const { include, toOneAccessFilters } = await buildAccessScopedInclude(
519
- { child: { include: { owner: true } } },
520
- config.lists.GrandChild.fields,
683
+ const { include, countDenials } = await buildAccessScopedInclude(
684
+ { _count: { select: { posts: true } } },
685
+ config.lists.User.fields,
521
686
  { session: null, context: makeContext() },
522
687
  config,
523
- 'GrandChild',
688
+ 'User',
524
689
  )
525
690
 
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
- },
691
+ expect(include).toEqual({})
692
+ expect(countDenials.keys).toEqual(new Set(['posts']))
693
+ })
694
+
695
+ it('does not reject a `_count.select` key naming a synthetic back-relation (#1082)', async () => {
696
+ const config = {
697
+ db: { provider: 'sqlite' },
698
+ lists: {
699
+ Category: {
700
+ fields: { name: { type: 'text' } as FieldConfig },
701
+ access: { operation: { query: () => true } },
702
+ },
703
+ Post: {
704
+ fields: {
705
+ title: { type: 'text' } as FieldConfig,
706
+ // A list-only ref: no `Category` field names it back, so schema
707
+ // generation synthesizes `from_Post_category` on Category.
708
+ category: rel('Category'),
537
709
  },
538
- nested: {},
710
+ access: { operation: { query: () => true } },
539
711
  },
540
712
  },
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)
713
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
714
+ } as any as OpenSaasConfig
559
715
 
560
- const resolved = await resolveToOneAccessVisibility(
561
- [{ id: 'c1' }],
562
- { filters: { owner: { kind: 'denied' } }, nested: {} },
563
- { session: null, context },
716
+ const { include } = await buildAccessScopedInclude(
717
+ { _count: { select: { from_Post_category: true } } },
718
+ config.lists.Category.fields,
719
+ { session: null, context: makeContext() },
720
+ config,
721
+ 'Category',
564
722
  )
565
723
 
566
- expect(resolved).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
567
- expect(findMany).not.toHaveBeenCalled()
724
+ expect(include).toEqual({ _count: { select: { from_Post_category: true } } })
568
725
  })
569
726
 
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' } },
727
+ it('`_count: true` also expands to a synthetic back-relation, scoped (#1082/#1087)', async () => {
728
+ const config = {
729
+ db: { provider: 'sqlite' },
730
+ lists: {
731
+ Category: {
732
+ fields: { name: { type: 'text' } as FieldConfig },
733
+ access: { operation: { query: () => true } },
734
+ },
735
+ Post: {
736
+ fields: {
737
+ title: { type: 'text' } as FieldConfig,
738
+ category: rel('Category'),
739
+ },
740
+ // A row filter — proves the synthetic relation's count is scoped
741
+ // exactly like an explicitly-named one, not merely included.
742
+ access: { operation: { query: () => ({ published: { equals: true } }) } },
579
743
  },
580
744
  },
581
- nested: {},
582
- }
745
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
746
+ } as any as OpenSaasConfig
583
747
 
584
- const resolved = await resolveToOneAccessVisibility([{ id: 'c1', owner: null }], tree, {
585
- session: null,
586
- context,
587
- })
748
+ const { include } = await buildAccessScopedInclude(
749
+ { _count: true },
750
+ config.lists.Category.fields,
751
+ { session: null, context: makeContext() },
752
+ config,
753
+ 'Category',
754
+ )
588
755
 
589
- expect(resolved).toEqual({
590
- filters: { owner: { kind: 'visible', ids: new Set() } },
591
- nested: {},
756
+ expect(include).toEqual({
757
+ _count: { select: { from_Post_category: { where: { published: { equals: true } } } } },
592
758
  })
593
- expect(findMany).not.toHaveBeenCalled()
594
759
  })
595
760
 
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' } },
761
+ it('scopes a relation filter nested inside a caller-supplied count `where` (e.g. `_count.select.posts.where.comments.some`)', async () => {
762
+ const config = {
763
+ db: { provider: 'sqlite' },
764
+ lists: {
765
+ User: {
766
+ fields: { name: { type: 'text' } as FieldConfig, posts: rel('Post.author', true) },
767
+ access: { operation: { query: () => true } },
768
+ },
769
+ Post: {
770
+ fields: {
771
+ title: { type: 'text' } as FieldConfig,
772
+ author: rel('User.posts'),
773
+ comments: rel('Comment.post', true),
774
+ },
775
+ access: { operation: { query: () => true } },
776
+ },
777
+ Comment: {
778
+ fields: { body: { type: 'text' } as FieldConfig, post: rel('Post.comments') },
779
+ // A row filter one hop past the counted relation — must still be
780
+ // folded in, or the resulting post count would reveal whether
781
+ // inaccessible comments matching the caller's filter exist.
782
+ access: { operation: { query: () => ({ approved: { equals: true } }) } },
605
783
  },
606
784
  },
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
- ]
785
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
786
+ } as any as OpenSaasConfig
615
787
 
616
- const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
788
+ const { include } = await buildAccessScopedInclude(
789
+ {
790
+ _count: {
791
+ select: {
792
+ posts: { where: { comments: { some: { body: { contains: 'hi' } } } } },
793
+ },
794
+ },
795
+ },
796
+ config.lists.User.fields,
797
+ { session: null, context: makeContext() },
798
+ config,
799
+ 'User',
800
+ )
617
801
 
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 },
802
+ expect(include).toEqual({
803
+ _count: {
804
+ select: {
805
+ posts: {
806
+ where: {
807
+ comments: {
808
+ some: {
809
+ AND: [{ approved: { equals: true } }, { body: { contains: 'hi' } }],
810
+ },
811
+ },
812
+ },
813
+ },
814
+ },
815
+ },
623
816
  })
624
- expect(resolved.filters.owner).toEqual({ kind: 'visible', ids: new Set(['u1', 'u2']) })
625
817
  })
626
818
 
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
819
+ it('resolves a synthetic back-relation named inside a caller-supplied count `where` (#1082/#1092)', async () => {
820
+ const config = {
821
+ db: { provider: 'sqlite' },
822
+ lists: {
823
+ User: {
824
+ fields: { name: { type: 'text' } as FieldConfig, posts: rel('Post.author', true) },
825
+ access: { operation: { query: () => true } },
826
+ },
827
+ Post: {
828
+ fields: {
829
+ title: { type: 'text' } as FieldConfig,
830
+ author: rel('User.posts'),
831
+ // A list-only ref: no `Post` field names it back, so schema
832
+ // generation synthesizes `from_Category_posts` on Post.
833
+ category: rel('Category'),
834
+ },
835
+ access: { operation: { query: () => true } },
836
+ },
837
+ Category: {
838
+ fields: {
839
+ name: { type: 'text' } as FieldConfig,
840
+ posts: rel('Post', true),
841
+ },
842
+ // A row filter one hop past the counted relation, reached only
843
+ // through the synthetic back-relation — must still be folded in.
844
+ access: { operation: { query: () => ({ published: { equals: true } }) } },
845
+ },
846
+ },
847
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
848
+ } as any as OpenSaasConfig
849
+
850
+ const { include } = await buildAccessScopedInclude(
851
+ {
852
+ _count: {
853
+ select: {
854
+ posts: { where: { from_Category_posts: { some: { name: { contains: 'news' } } } } },
855
+ },
856
+ },
857
+ },
858
+ config.lists.User.fields,
859
+ { session: null, context: makeContext() },
860
+ config,
861
+ 'User',
862
+ )
863
+
864
+ expect(include).toEqual({
865
+ _count: {
866
+ select: {
867
+ posts: {
868
+ where: {
869
+ from_Category_posts: {
870
+ some: {
871
+ AND: [{ published: { equals: true } }, { name: { contains: 'news' } }],
872
+ },
873
+ },
874
+ },
875
+ },
876
+ },
877
+ },
878
+ })
879
+ })
880
+
881
+ it('`_count: true` expands to every declared to-many relation, scoped', async () => {
882
+ const config = countConfig(() => true)
883
+
884
+ const { include, countDenials } = await buildAccessScopedInclude(
885
+ { _count: true },
886
+ config.lists.User.fields,
887
+ { session: null, context: makeContext() },
888
+ config,
889
+ 'User',
890
+ )
891
+
892
+ expect(include).toEqual({ _count: { select: { posts: true } } })
893
+ expect(countDenials.keys).toEqual(new Set(['secrets']))
894
+ })
895
+
896
+ it('scopes `_count` nested inside an ordinary relation include, at that level', async () => {
897
+ const config = {
898
+ db: { provider: 'sqlite' },
899
+ lists: {
900
+ Org: {
901
+ fields: { name: { type: 'text' } as FieldConfig, users: rel('User.org', true) },
902
+ access: { operation: { query: () => true } },
903
+ },
904
+ User: {
905
+ fields: {
906
+ name: { type: 'text' } as FieldConfig,
907
+ org: rel('Org.users'),
908
+ posts: rel('Post.author', true),
909
+ },
910
+ access: { operation: { query: () => true } },
911
+ },
912
+ Post: {
913
+ fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
914
+ access: { operation: { query: () => ({ published: { equals: true } }) } },
915
+ },
916
+ },
917
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
918
+ } as any as OpenSaasConfig
919
+
920
+ const { include } = await buildAccessScopedInclude(
921
+ { users: { include: { _count: { select: { posts: true } } } } },
922
+ config.lists.Org.fields,
923
+ { session: null, context: makeContext() },
924
+ config,
925
+ 'Org',
926
+ )
927
+
928
+ expect(include).toEqual({
929
+ users: {
930
+ include: { _count: { select: { posts: { where: { published: { equals: true } } } } } },
931
+ },
932
+ })
933
+ })
934
+ })
935
+
936
+ /**
937
+ * Regression coverage for issue #974: Prisma accepts a nested `where` on an
938
+ * `include` entry only for a to-many relation — the same shape on a to-one
939
+ * relation raises `PrismaClientValidationError`. `buildAccessScopedInclude`
940
+ * must never attach one for a to-one relation, whatever its related list's
941
+ * `query` access resolves to; the scoping information goes into
942
+ * `toOneAccessFilters` instead.
943
+ */
944
+ describe('buildAccessScopedInclude — to-one relations record a post-query filter, not `where` (issue #974)', () => {
945
+ function ownerConfig(queryAccess: unknown): OpenSaasConfig {
946
+ return {
947
+ db: { provider: 'sqlite' },
948
+ lists: {
949
+ Child: {
950
+ fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
951
+ access: { operation: { query: () => true } },
952
+ },
953
+ Owner: {
954
+ fields: { name: { type: 'text' } as FieldConfig },
955
+ access: { operation: { query: queryAccess } },
956
+ },
957
+ },
958
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
635
959
  } as any
636
- const tree = {
960
+ }
961
+
962
+ it('records the access filter instead of attaching `where` when the related list scopes by a filter', async () => {
963
+ const config = ownerConfig(() => ({ id: { equals: 'u1' } }))
964
+
965
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
966
+ { owner: true },
967
+ config.lists.Child.fields,
968
+ { session: null, context: makeContext() },
969
+ config,
970
+ 'Child',
971
+ )
972
+
973
+ // The Prisma-bound include never carries a `where` on this to-one key —
974
+ // Prisma would reject it.
975
+ expect(include).toEqual({ owner: true })
976
+ expect(toOneAccessFilters).toEqual({
977
+ filters: {
978
+ owner: { kind: 'scoped', relatedListName: 'Owner', accessWhere: { id: { equals: 'u1' } } },
979
+ },
980
+ nested: {},
981
+ })
982
+ })
983
+
984
+ it('leaves a to-one relation whose related list is fully open untouched — no filter recorded, no extra query later', async () => {
985
+ const config = ownerConfig(() => true)
986
+
987
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
988
+ { owner: true },
989
+ config.lists.Child.fields,
990
+ { session: null, context: makeContext() },
991
+ config,
992
+ 'Child',
993
+ )
994
+
995
+ expect(include).toEqual({ owner: true })
996
+ expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
997
+ })
998
+
999
+ it('drops a to-one relation whose related list denies query access outright, and records the denial', async () => {
1000
+ const config = ownerConfig(() => false)
1001
+
1002
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1003
+ { owner: true },
1004
+ config.lists.Child.fields,
1005
+ { session: null, context: makeContext() },
1006
+ config,
1007
+ 'Child',
1008
+ )
1009
+
1010
+ // Never asked of Prisma at all — same as before this fix.
1011
+ expect(include).toEqual({})
1012
+ expect(toOneAccessFilters).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
1013
+ })
1014
+
1015
+ it('records a to-one filter nested inside another relation the caller explicitly included', async () => {
1016
+ const config: OpenSaasConfig = {
1017
+ db: { provider: 'sqlite' },
1018
+ lists: {
1019
+ GrandChild: {
1020
+ fields: { name: { type: 'text' } as FieldConfig, child: rel('Child') },
1021
+ access: { operation: { query: () => true } },
1022
+ },
1023
+ Child: {
1024
+ fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
1025
+ access: { operation: { query: () => true } },
1026
+ },
1027
+ Owner: {
1028
+ fields: { name: { type: 'text' } as FieldConfig },
1029
+ access: { operation: { query: () => ({ id: { equals: 'u1' } }) } },
1030
+ },
1031
+ },
1032
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1033
+ } as any
1034
+
1035
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1036
+ { child: { include: { owner: true } } },
1037
+ config.lists.GrandChild.fields,
1038
+ { session: null, context: makeContext() },
1039
+ config,
1040
+ 'GrandChild',
1041
+ )
1042
+
1043
+ expect(include).toEqual({ child: { include: { owner: true } } })
1044
+ expect(toOneAccessFilters).toEqual({
637
1045
  filters: {},
638
1046
  nested: {
639
- children: {
1047
+ child: {
640
1048
  filters: {
641
1049
  owner: {
642
- kind: 'scoped' as const,
1050
+ kind: 'scoped',
643
1051
  relatedListName: 'Owner',
644
1052
  accessWhere: { id: { equals: 'u1' } },
645
1053
  },
@@ -647,14 +1055,238 @@ describe('resolveToOneAccessVisibility (issue #974)', () => {
647
1055
  nested: {},
648
1056
  },
649
1057
  },
650
- }
651
- const items = [
652
- {
653
- id: 'p1',
654
- children: [
655
- { id: 'c1', owner: { id: 'u1' } },
656
- { id: 'c2', owner: { id: 'u9' } },
657
- ],
1058
+ })
1059
+ })
1060
+ })
1061
+
1062
+ /**
1063
+ * Regression coverage for issue #1103: a denied to-MANY relation used to be
1064
+ * dropped from `include` with nothing recorded, unlike its to-one sibling
1065
+ * (issue #974, covered above). `filterReadableFields`'s main loop only ever
1066
+ * visits keys present in the raw row, so an unrecorded denial left the key
1067
+ * silently absent from the result — `undefined`, not `[]` — breaking the
1068
+ * fragment API's typed contract (`ResultOf` types a to-many relation as an
1069
+ * array). These tests pin that a to-many denial is now recorded exactly like
1070
+ * a to-one one, sharing the same `toOneAccessFilters` tree and `kind:
1071
+ * 'denied'` entry.
1072
+ */
1073
+ describe('buildAccessScopedInclude — to-many relations record a denial too, like to-one (issue #1103)', () => {
1074
+ function secretsConfig(): OpenSaasConfig {
1075
+ return {
1076
+ db: { provider: 'sqlite' },
1077
+ lists: {
1078
+ Author: {
1079
+ fields: { name: { type: 'text' } as FieldConfig, secrets: rel('Secret.author', true) },
1080
+ access: { operation: { query: () => true } },
1081
+ },
1082
+ Secret: {
1083
+ fields: { value: { type: 'text' } as FieldConfig },
1084
+ access: { operation: { query: () => false } },
1085
+ },
1086
+ },
1087
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1088
+ } as any
1089
+ }
1090
+
1091
+ it('drops a to-many relation whose related list denies query access outright, and records the denial', async () => {
1092
+ const config = secretsConfig()
1093
+
1094
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1095
+ { secrets: true },
1096
+ config.lists.Author.fields,
1097
+ { session: null, context: makeContext() },
1098
+ config,
1099
+ 'Author',
1100
+ )
1101
+
1102
+ // Never asked of Prisma at all — same treatment as a denied to-one.
1103
+ expect(include).toEqual({})
1104
+ expect(toOneAccessFilters).toEqual({ filters: { secrets: { kind: 'denied' } }, nested: {} })
1105
+ })
1106
+
1107
+ it('records the denial even when the caller also asked for a `take`/`where` — a denied relation has no shape to preserve', async () => {
1108
+ const config = secretsConfig()
1109
+
1110
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1111
+ { secrets: { take: 5, where: { value: { equals: 'x' } } } },
1112
+ config.lists.Author.fields,
1113
+ { session: null, context: makeContext() },
1114
+ config,
1115
+ 'Author',
1116
+ )
1117
+
1118
+ expect(include).toEqual({})
1119
+ expect(toOneAccessFilters).toEqual({ filters: { secrets: { kind: 'denied' } }, nested: {} })
1120
+ })
1121
+
1122
+ // Code-review finding on #1103's own PR: a caller's `Include` type allows a
1123
+ // bare `boolean` for a relation, so `{ secrets: false }` (or a conditional
1124
+ // `{ secrets: cond ? true : undefined }` that resolved to `undefined`)
1125
+ // explicitly opts the relation OUT — no different from the key being
1126
+ // absent altogether. Before this guard, the loop evaluated (and, once
1127
+ // denied, recorded) the key regardless of its value, so a denied relation
1128
+ // the caller explicitly disabled still ended up forced to `[]` downstream
1129
+ // by `field-visibility.ts` — surfacing a value for a key nobody asked for.
1130
+ it('treats an explicit `false` include value as not requested, even when the related list denies query access', async () => {
1131
+ const config = secretsConfig()
1132
+
1133
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1134
+ { secrets: false },
1135
+ config.lists.Author.fields,
1136
+ { session: null, context: makeContext() },
1137
+ config,
1138
+ 'Author',
1139
+ )
1140
+
1141
+ expect(include).toEqual({})
1142
+ expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
1143
+ })
1144
+
1145
+ it('treats an explicit `undefined` include value as not requested, even when the related list denies query access', async () => {
1146
+ const config = secretsConfig()
1147
+
1148
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1149
+ { secrets: undefined },
1150
+ config.lists.Author.fields,
1151
+ { session: null, context: makeContext() },
1152
+ config,
1153
+ 'Author',
1154
+ )
1155
+
1156
+ expect(include).toEqual({})
1157
+ expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
1158
+ })
1159
+
1160
+ it('treats an explicit `false` include value as not requested even when the related list is fully open — never flips it to `true`', async () => {
1161
+ const config = secretsConfig()
1162
+ config.lists.Secret.access = { operation: { query: () => true } }
1163
+
1164
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1165
+ { secrets: false },
1166
+ config.lists.Author.fields,
1167
+ { session: null, context: makeContext() },
1168
+ config,
1169
+ 'Author',
1170
+ )
1171
+
1172
+ expect(include).toEqual({})
1173
+ expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
1174
+ })
1175
+ })
1176
+
1177
+ describe('resolveToOneAccessVisibility (issue #974)', () => {
1178
+ function makeVisibilityContext(findMany: ReturnType<typeof vi.fn>): AccessContext {
1179
+ return {
1180
+ session: null,
1181
+ _isSudo: false,
1182
+ _resolveOutputChain: [],
1183
+ prisma: { owner: { findMany } },
1184
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
1185
+ } as any
1186
+ }
1187
+
1188
+ it('passes a denied filter straight through with no query', async () => {
1189
+ const findMany = vi.fn()
1190
+ const context = makeVisibilityContext(findMany)
1191
+
1192
+ const resolved = await resolveToOneAccessVisibility(
1193
+ [{ id: 'c1' }],
1194
+ { filters: { owner: { kind: 'denied' } }, nested: {} },
1195
+ { session: null, context },
1196
+ )
1197
+
1198
+ expect(resolved).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
1199
+ expect(findMany).not.toHaveBeenCalled()
1200
+ })
1201
+
1202
+ it('skips the query when no row carries a value at the scoped key', async () => {
1203
+ const findMany = vi.fn()
1204
+ const context = makeVisibilityContext(findMany)
1205
+ const tree = {
1206
+ filters: {
1207
+ owner: {
1208
+ kind: 'scoped' as const,
1209
+ relatedListName: 'Owner',
1210
+ accessWhere: { id: { equals: 'u1' } },
1211
+ },
1212
+ },
1213
+ nested: {},
1214
+ }
1215
+
1216
+ const resolved = await resolveToOneAccessVisibility([{ id: 'c1', owner: null }], tree, {
1217
+ session: null,
1218
+ context,
1219
+ })
1220
+
1221
+ expect(resolved).toEqual({
1222
+ filters: { owner: { kind: 'visible', ids: new Set() } },
1223
+ nested: {},
1224
+ })
1225
+ expect(findMany).not.toHaveBeenCalled()
1226
+ })
1227
+
1228
+ it('batches every row into ONE existence check, through the raw Prisma client, using the exact access filter', async () => {
1229
+ const findMany = vi.fn().mockResolvedValue([{ id: 'u1' }, { id: 'u2' }])
1230
+ const context = makeVisibilityContext(findMany)
1231
+ const tree = {
1232
+ filters: {
1233
+ owner: {
1234
+ kind: 'scoped' as const,
1235
+ relatedListName: 'Owner',
1236
+ accessWhere: { studioId: { equals: 's1' } },
1237
+ },
1238
+ },
1239
+ nested: {},
1240
+ }
1241
+ const items = [
1242
+ { id: 'c1', owner: { id: 'u1' } },
1243
+ { id: 'c2', owner: { id: 'u2' } },
1244
+ { id: 'c3', owner: { id: 'u3' } }, // excluded by the mocked response below
1245
+ { id: 'c4', owner: null }, // contributes no id
1246
+ ]
1247
+
1248
+ const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
1249
+
1250
+ // One call, not one per row.
1251
+ expect(findMany).toHaveBeenCalledTimes(1)
1252
+ expect(findMany).toHaveBeenCalledWith({
1253
+ where: { AND: [{ studioId: { equals: 's1' } }, { id: { in: ['u1', 'u2', 'u3'] } }] },
1254
+ select: { id: true },
1255
+ })
1256
+ expect(resolved.filters.owner).toEqual({ kind: 'visible', ids: new Set(['u1', 'u2']) })
1257
+ })
1258
+
1259
+ it('recurses into nested trees, flattening rows reached through a to-many hop into one batch', async () => {
1260
+ const ownerFindMany = vi.fn().mockResolvedValue([{ id: 'u1' }])
1261
+ const context: AccessContext = {
1262
+ session: null,
1263
+ _isSudo: false,
1264
+ _resolveOutputChain: [],
1265
+ prisma: { owner: { findMany: ownerFindMany } },
1266
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
1267
+ } as any
1268
+ const tree = {
1269
+ filters: {},
1270
+ nested: {
1271
+ children: {
1272
+ filters: {
1273
+ owner: {
1274
+ kind: 'scoped' as const,
1275
+ relatedListName: 'Owner',
1276
+ accessWhere: { id: { equals: 'u1' } },
1277
+ },
1278
+ },
1279
+ nested: {},
1280
+ },
1281
+ },
1282
+ }
1283
+ const items = [
1284
+ {
1285
+ id: 'p1',
1286
+ children: [
1287
+ { id: 'c1', owner: { id: 'u1' } },
1288
+ { id: 'c2', owner: { id: 'u9' } },
1289
+ ],
658
1290
  },
659
1291
  { id: 'p2', children: [{ id: 'c3', owner: { id: 'u1' } }] },
660
1292
  ]
@@ -673,3 +1305,615 @@ describe('resolveToOneAccessVisibility (issue #974)', () => {
673
1305
  })
674
1306
  })
675
1307
  })
1308
+
1309
+ /**
1310
+ * Regression coverage for issue #1082: a list-only `ref` (`ref: 'Term'`, no
1311
+ * target field) makes schema generation synthesize a back-relation on `Term`
1312
+ * (`from_Bill_term`) that no list config declares. Before this fix,
1313
+ * `buildAccessScopedInclude` treated any key it couldn't resolve to a
1314
+ * DECLARED relationship as "not governed by access control" and passed it
1315
+ * through unchanged — so a caller naming `from_Bill_term` in `include`
1316
+ * bypassed `Bill`'s own `query` access entirely. These tests pin the fix: a
1317
+ * synthetic key is scoped exactly like the declared relationship field it
1318
+ * stands for.
1319
+ */
1320
+ describe('buildAccessScopedInclude — synthetic back-relation (#1082)', () => {
1321
+ // Term ← Bill.term (list-only ref) ← Bill.lineItems (declared, to-many).
1322
+ // `from_Bill_term` is the back-relation schema generation synthesizes on
1323
+ // Term; nothing in `Term.fields` declares it.
1324
+ function syntheticConfig(billQuery: () => boolean | Record<string, unknown>) {
1325
+ const config = {
1326
+ db: { provider: 'sqlite' },
1327
+ lists: {
1328
+ Term: {
1329
+ fields: { name: { type: 'text' } as FieldConfig },
1330
+ access: { operation: { query: () => true } },
1331
+ },
1332
+ Bill: {
1333
+ fields: {
1334
+ amount: { type: 'integer' } as FieldConfig,
1335
+ term: rel('Term'), // list-only ref — synthesizes `from_Bill_term` on Term
1336
+ lineItems: rel('LineItem.bill', true),
1337
+ },
1338
+ access: { operation: { query: billQuery } },
1339
+ },
1340
+ LineItem: {
1341
+ fields: { sku: { type: 'text' } as FieldConfig, bill: rel('Bill.lineItems') },
1342
+ access: { operation: { query: () => true } },
1343
+ },
1344
+ },
1345
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1346
+ } as any as OpenSaasConfig
1347
+ return config
1348
+ }
1349
+
1350
+ it('drops the relation entirely when the owning list denies query access', async () => {
1351
+ const config = syntheticConfig(() => false)
1352
+
1353
+ const { include } = await buildAccessScopedInclude(
1354
+ { from_Bill_term: true },
1355
+ config.lists.Term.fields,
1356
+ { session: null, context: makeContext() },
1357
+ config,
1358
+ 'Term',
1359
+ )
1360
+
1361
+ expect(include).toEqual({})
1362
+ })
1363
+
1364
+ it('AND-folds the owning list access filter with the caller-supplied where', async () => {
1365
+ const config = syntheticConfig(() => ({ paid: { equals: true } }))
1366
+
1367
+ const { include } = await buildAccessScopedInclude(
1368
+ { from_Bill_term: { where: { amount: { gt: 10 } } } },
1369
+ config.lists.Term.fields,
1370
+ { session: null, context: makeContext() },
1371
+ config,
1372
+ 'Term',
1373
+ )
1374
+
1375
+ expect(include).toEqual({
1376
+ from_Bill_term: {
1377
+ where: { AND: [{ paid: { equals: true } }, { amount: { gt: 10 } }] },
1378
+ },
1379
+ })
1380
+ })
1381
+
1382
+ it('scopes a nested include against the OWNING list fields and consumes depth', async () => {
1383
+ const config = syntheticConfig(() => true)
1384
+ const lineItemQuery = vi.fn(() => true)
1385
+ config.lists.LineItem.access = { operation: { query: lineItemQuery } }
1386
+
1387
+ const { include } = await buildAccessScopedInclude(
1388
+ { from_Bill_term: { include: { lineItems: true } } },
1389
+ config.lists.Term.fields,
1390
+ { session: null, context: makeContext() },
1391
+ config,
1392
+ 'Term',
1393
+ )
1394
+
1395
+ // The nested `lineItems` key was resolved against Bill's own fields (not
1396
+ // Term's, which has no `lineItems` field at all) — proof the recursion
1397
+ // used the owning list, not the synthetic key's declaring list.
1398
+ expect(lineItemQuery).toHaveBeenCalledTimes(1)
1399
+ expect(include).toEqual({ from_Bill_term: { include: { lineItems: true } } })
1400
+ })
1401
+
1402
+ it('a synthetic hop counts toward the read-include depth cap the same as a declared one', async () => {
1403
+ // A chain like `chainConfig` builds (L0 ↔ L1 ↔ … via declared `next`/`prev`),
1404
+ // except the FIRST hop is synthetic: L1's `prev` is a list-only ref, so L0
1405
+ // never declares `next` at all — it only gets `from_L1_prev` synthesized
1406
+ // onto it. Every hop past that first one is declared, exactly matching the
1407
+ // mixed synthetic-then-declared case the depth cap must bound identically.
1408
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 2
1409
+ const { config } = chainConfig(chainLength)
1410
+ delete (config.lists.L0.fields as Record<string, unknown>).next
1411
+ ;(config.lists.L1.fields as Record<string, FieldConfig>).prev = rel('L0')
1412
+
1413
+ // Outer `from_L1_prev` (hop 1) + nestedInclude(HOPS_AT_CAP) (HOPS_AT_CAP
1414
+ // more, via the declared `next` chain from L1 onward) = HOPS_AT_CAP + 1.
1415
+ const requested = { from_L1_prev: nestedInclude(READ_INCLUDE_MAX_DEPTH) }
1416
+
1417
+ await expect(
1418
+ buildAccessScopedInclude(
1419
+ requested,
1420
+ config.lists.L0.fields,
1421
+ { session: null, context: makeContext() },
1422
+ config,
1423
+ 'L0',
1424
+ ),
1425
+ ).rejects.toThrow(AccessScopeDepthExceededError)
1426
+ })
1427
+
1428
+ it('still resolves a synthetic hop one level shallower than the cap', async () => {
1429
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 1
1430
+ const { config } = chainConfig(chainLength)
1431
+ delete (config.lists.L0.fields as Record<string, unknown>).next
1432
+ ;(config.lists.L1.fields as Record<string, FieldConfig>).prev = rel('L0')
1433
+
1434
+ const requested = { from_L1_prev: nestedInclude(READ_INCLUDE_MAX_DEPTH - 1) }
1435
+
1436
+ const { include } = await buildAccessScopedInclude(
1437
+ requested,
1438
+ config.lists.L0.fields,
1439
+ { session: null, context: makeContext() },
1440
+ config,
1441
+ 'L0',
1442
+ )
1443
+
1444
+ expect(includeDepth(include)).toBe(READ_INCLUDE_MAX_DEPTH)
1445
+ })
1446
+
1447
+ it('rejects a key that resolves to neither a declared relationship nor a synthetic one', async () => {
1448
+ const config = syntheticConfig(() => true)
1449
+
1450
+ await expect(
1451
+ buildAccessScopedInclude(
1452
+ { from_Nonexistent_field: true },
1453
+ config.lists.Term.fields,
1454
+ { session: null, context: makeContext() },
1455
+ config,
1456
+ 'Term',
1457
+ ),
1458
+ ).rejects.toThrow(UndeclaredIncludeKeyError)
1459
+ })
1460
+ })
1461
+
1462
+ /**
1463
+ * Regression coverage for issue #1092: a `where`/`orderBy` a caller nests
1464
+ * inside an `include` entry reached Prisma with neither #912's key-existence
1465
+ * check nor #915's field-read check — a probing oracle over exactly the
1466
+ * fields those two tickets close one level up. `secret`/`secretNote` below
1467
+ * deny field-level `read` outright; every other field is a normal declared
1468
+ * field, so a throw in these tests can only come from the new validation.
1469
+ */
1470
+ describe('buildAccessScopedInclude — nested where/orderBy validation (#1092)', () => {
1471
+ function blogConfig(): OpenSaasConfig {
1472
+ return {
1473
+ db: { provider: 'sqlite' },
1474
+ lists: {
1475
+ Author: {
1476
+ fields: {
1477
+ name: { type: 'text' } as FieldConfig,
1478
+ posts: rel('Post.author', true),
1479
+ },
1480
+ access: { operation: { query: () => true } },
1481
+ },
1482
+ Post: {
1483
+ fields: {
1484
+ title: { type: 'text' } as FieldConfig,
1485
+ secret: { type: 'text', access: { read: () => false } } as unknown as FieldConfig,
1486
+ author: rel('Author.posts'),
1487
+ comments: rel('Comment.post', true),
1488
+ },
1489
+ access: { operation: { query: () => true } },
1490
+ },
1491
+ Comment: {
1492
+ fields: {
1493
+ body: { type: 'text' } as FieldConfig,
1494
+ secretNote: { type: 'text', access: { read: () => false } } as unknown as FieldConfig,
1495
+ post: rel('Post.comments'),
1496
+ },
1497
+ access: { operation: { query: () => true } },
1498
+ },
1499
+ },
1500
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1501
+ } as any as OpenSaasConfig
1502
+ }
1503
+
1504
+ it('throws when a nested `where` names a field the session cannot read', async () => {
1505
+ const config = blogConfig()
1506
+
1507
+ await expect(
1508
+ buildAccessScopedInclude(
1509
+ { posts: { where: { secret: { equals: 'x' } } } },
1510
+ config.lists.Author.fields,
1511
+ { session: null, context: makeContext() },
1512
+ config,
1513
+ 'Author',
1514
+ ),
1515
+ ).rejects.toThrow(ValidationError)
1516
+ await expect(
1517
+ buildAccessScopedInclude(
1518
+ { posts: { where: { secret: { equals: 'x' } } } },
1519
+ config.lists.Author.fields,
1520
+ { session: null, context: makeContext() },
1521
+ config,
1522
+ 'Author',
1523
+ ),
1524
+ ).rejects.toThrow(/"secret" is denied by field-level read access/)
1525
+ })
1526
+
1527
+ it('throws when a nested `where` names a key the related list does not declare, naming the related list and key', async () => {
1528
+ const config = blogConfig()
1529
+
1530
+ await expect(
1531
+ buildAccessScopedInclude(
1532
+ { posts: { where: { bogusField: { equals: 'x' } } } },
1533
+ config.lists.Author.fields,
1534
+ { session: null, context: makeContext() },
1535
+ config,
1536
+ 'Author',
1537
+ ),
1538
+ ).rejects.toThrow(/Cannot query "Post" — "bogusField" is not a field of this list/)
1539
+ })
1540
+
1541
+ it('applies the same two checks to a nested `orderBy`', async () => {
1542
+ const config = blogConfig()
1543
+
1544
+ await expect(
1545
+ buildAccessScopedInclude(
1546
+ { posts: { orderBy: { secret: 'asc' } } },
1547
+ config.lists.Author.fields,
1548
+ { session: null, context: makeContext() },
1549
+ config,
1550
+ 'Author',
1551
+ ),
1552
+ ).rejects.toThrow(ValidationError)
1553
+
1554
+ await expect(
1555
+ buildAccessScopedInclude(
1556
+ { posts: { orderBy: { bogusField: 'asc' } } },
1557
+ config.lists.Author.fields,
1558
+ { session: null, context: makeContext() },
1559
+ config,
1560
+ 'Author',
1561
+ ),
1562
+ ).rejects.toThrow(/Cannot query "Post"/)
1563
+ })
1564
+
1565
+ it('resolves against the RELATED list, not the current one — a key valid on the parent but not the related list is rejected', async () => {
1566
+ const config = blogConfig()
1567
+
1568
+ // `name` is declared on Author, not on Post — validating against the
1569
+ // wrong list would let this through.
1570
+ await expect(
1571
+ buildAccessScopedInclude(
1572
+ { posts: { where: { name: { equals: 'x' } } } },
1573
+ config.lists.Author.fields,
1574
+ { session: null, context: makeContext() },
1575
+ config,
1576
+ 'Author',
1577
+ ),
1578
+ ).rejects.toThrow(/Cannot query "Post" — "name"/)
1579
+ })
1580
+
1581
+ it('recurses through AND/OR/NOT', async () => {
1582
+ const config = blogConfig()
1583
+
1584
+ await expect(
1585
+ buildAccessScopedInclude(
1586
+ {
1587
+ posts: {
1588
+ where: { AND: [{ title: { contains: 'x' } }, { OR: [{ secret: { equals: 'x' } }] }] },
1589
+ },
1590
+ },
1591
+ config.lists.Author.fields,
1592
+ { session: null, context: makeContext() },
1593
+ config,
1594
+ 'Author',
1595
+ ),
1596
+ ).rejects.toThrow(ValidationError)
1597
+ })
1598
+
1599
+ it("the key-existence check recurses through a relation quantifier nested inside the entry's own where", async () => {
1600
+ // #912's existence check (`validateQueryKeys`) is the reused #912/#915
1601
+ // top-level walker, which already recurses through a relation quantifier
1602
+ // regardless of caller position — an undeclared key nested that deep
1603
+ // still rejects, naming the list it actually resolved against (Comment,
1604
+ // not Post).
1605
+ const config = blogConfig()
1606
+
1607
+ await expect(
1608
+ buildAccessScopedInclude(
1609
+ { posts: { where: { comments: { some: { bogusField: { equals: 'x' } } } } } },
1610
+ config.lists.Author.fields,
1611
+ { session: null, context: makeContext() },
1612
+ config,
1613
+ 'Author',
1614
+ ),
1615
+ ).rejects.toThrow(/Cannot query "Comment" — "bogusField"/)
1616
+ })
1617
+
1618
+ it("scopes a relation quantifier nested inside the entry's own where against the DEEPER related list — a read-denied field one hop further out still throws (#916)", async () => {
1619
+ // Without this, #1092's fix would only move the oracle one hop further
1620
+ // out instead of closing it: `posts: { where: { comments: { some: {...} } } }`
1621
+ // names Comment, a list the entry's own #912/#915 checks never reach —
1622
+ // only `buildAccessScopedWhere` (#916), reused here, does.
1623
+ const config = blogConfig()
1624
+
1625
+ await expect(
1626
+ buildAccessScopedInclude(
1627
+ { posts: { where: { comments: { some: { secretNote: { equals: 'x' } } } } } },
1628
+ config.lists.Author.fields,
1629
+ { session: null, context: makeContext() },
1630
+ config,
1631
+ 'Author',
1632
+ ),
1633
+ ).rejects.toThrow(/Cannot query "Comment" — "secretNote"/)
1634
+ })
1635
+
1636
+ it("folds the deeper related list's own query access into a relation quantifier nested inside the entry's own where", async () => {
1637
+ const config = blogConfig()
1638
+ config.lists.Comment.access = {
1639
+ operation: { query: () => ({ approved: { equals: true } }) },
1640
+ }
1641
+
1642
+ const { include } = await buildAccessScopedInclude(
1643
+ { posts: { where: { comments: { some: { body: { contains: 'hi' } } } } } },
1644
+ config.lists.Author.fields,
1645
+ { session: null, context: makeContext() },
1646
+ config,
1647
+ 'Author',
1648
+ )
1649
+
1650
+ expect(include).toEqual({
1651
+ posts: {
1652
+ where: {
1653
+ comments: {
1654
+ some: { AND: [{ approved: { equals: true } }, { body: { contains: 'hi' } }] },
1655
+ },
1656
+ },
1657
+ },
1658
+ })
1659
+ })
1660
+
1661
+ it("throws when the deeper related list denies query access outright, matching #916's loud failure", async () => {
1662
+ const config = blogConfig()
1663
+ config.lists.Comment.access = { operation: { query: () => false } }
1664
+
1665
+ await expect(
1666
+ buildAccessScopedInclude(
1667
+ { posts: { where: { comments: { some: { body: { contains: 'hi' } } } } } },
1668
+ config.lists.Author.fields,
1669
+ { session: null, context: makeContext() },
1670
+ config,
1671
+ 'Author',
1672
+ ),
1673
+ ).rejects.toThrow(RelationFilterAccessDeniedError)
1674
+ })
1675
+
1676
+ it('recurses through a further nested include, at every level the walk reaches', async () => {
1677
+ const config = blogConfig()
1678
+
1679
+ await expect(
1680
+ buildAccessScopedInclude(
1681
+ { posts: { include: { comments: { where: { secretNote: { equals: 'x' } } } } } },
1682
+ config.lists.Author.fields,
1683
+ { session: null, context: makeContext() },
1684
+ config,
1685
+ 'Author',
1686
+ ),
1687
+ ).rejects.toThrow(/Cannot query "Comment" — "secretNote"/)
1688
+ })
1689
+
1690
+ it('accepts a foreign-key scalar a to-one relationship implies, matching the top-level resolver', async () => {
1691
+ const config = blogConfig()
1692
+
1693
+ await expect(
1694
+ buildAccessScopedInclude(
1695
+ { posts: { where: { authorId: { equals: 'a1' } } } },
1696
+ config.lists.Author.fields,
1697
+ { session: null, context: makeContext() },
1698
+ config,
1699
+ 'Author',
1700
+ ),
1701
+ ).resolves.not.toThrow()
1702
+ })
1703
+
1704
+ it('leaves a legitimate nested where over readable, declared fields unchanged, still AND-combined with the access filter', async () => {
1705
+ const config = blogConfig()
1706
+ config.lists.Post.access = { operation: { query: () => ({ published: { equals: true } }) } }
1707
+
1708
+ const { include } = await buildAccessScopedInclude(
1709
+ { posts: { where: { title: { contains: 'hello' } } } },
1710
+ config.lists.Author.fields,
1711
+ { session: null, context: makeContext() },
1712
+ config,
1713
+ 'Author',
1714
+ )
1715
+
1716
+ expect(include).toEqual({
1717
+ posts: {
1718
+ where: { AND: [{ published: { equals: true } }, { title: { contains: 'hello' } }] },
1719
+ },
1720
+ })
1721
+ })
1722
+
1723
+ it('equivalence: the same read-denied predicate throws whether it names the current list, a relation quantifier, or an include-nested where', async () => {
1724
+ // Mirrors the triage comment's three-position table for #1092 — the
1725
+ // include-nested position (the one gap) now matches the other two.
1726
+ const config = blogConfig()
1727
+
1728
+ await expect(
1729
+ buildAccessScopedInclude(
1730
+ { posts: { where: { secret: { equals: 'x' } } } },
1731
+ config.lists.Author.fields,
1732
+ { session: null, context: makeContext() },
1733
+ config,
1734
+ 'Author',
1735
+ ),
1736
+ ).rejects.toThrow(ValidationError)
1737
+ })
1738
+
1739
+ describe('synthetic back-relation nested in a where (#1082 interaction)', () => {
1740
+ function syntheticNestedConfig(): OpenSaasConfig {
1741
+ const config = {
1742
+ db: { provider: 'sqlite' },
1743
+ lists: {
1744
+ Company: {
1745
+ fields: {
1746
+ name: { type: 'text' } as FieldConfig,
1747
+ terms: rel('Term', true),
1748
+ },
1749
+ access: { operation: { query: () => true } },
1750
+ },
1751
+ Term: {
1752
+ fields: { name: { type: 'text' } as FieldConfig },
1753
+ access: { operation: { query: () => true } },
1754
+ },
1755
+ Bill: {
1756
+ fields: {
1757
+ amount: { type: 'integer' } as FieldConfig,
1758
+ term: rel('Term'), // list-only ref — synthesizes `from_Bill_term` on Term
1759
+ },
1760
+ access: { operation: { query: () => true } },
1761
+ },
1762
+ },
1763
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1764
+ } as any as OpenSaasConfig
1765
+ return config
1766
+ }
1767
+
1768
+ it('does not throw for a nested predicate naming a synthetic back-relation', async () => {
1769
+ const config = syntheticNestedConfig()
1770
+
1771
+ await expect(
1772
+ buildAccessScopedInclude(
1773
+ { terms: { where: { from_Bill_term: { some: { amount: { gt: 5 } } } } } },
1774
+ config.lists.Company.fields,
1775
+ { session: null, context: makeContext() },
1776
+ config,
1777
+ 'Company',
1778
+ ),
1779
+ ).resolves.not.toThrow()
1780
+ })
1781
+
1782
+ it("recurses into the synthetic's own SOURCE list — an invalid key nested under it still throws, naming that list", async () => {
1783
+ const config = syntheticNestedConfig()
1784
+
1785
+ await expect(
1786
+ buildAccessScopedInclude(
1787
+ { terms: { where: { from_Bill_term: { some: { bogusField: { equals: 'x' } } } } } },
1788
+ config.lists.Company.fields,
1789
+ { session: null, context: makeContext() },
1790
+ config,
1791
+ 'Company',
1792
+ ),
1793
+ ).rejects.toThrow(/Cannot query "Bill" — "bogusField"/)
1794
+ })
1795
+
1796
+ it("folds the synthetic's own SOURCE list's query access into the nested quantifier clause", async () => {
1797
+ // Regression coverage for a Codex review finding on #1108: the deeper
1798
+ // #916-style scoping originally only recognised a DECLARED
1799
+ // relationship — a synthetic key's nested quantifier passed through
1800
+ // with neither the source list's `query` access folded in nor its
1801
+ // fields' read access checked, even though the shallow #912/#915
1802
+ // checks (validateQueryKeys/validateQueryFieldReadAccess) already
1803
+ // correctly tolerated and resolved the synthetic key itself.
1804
+ const config = syntheticNestedConfig()
1805
+ config.lists.Bill.access = { operation: { query: () => ({ paid: { equals: true } }) } }
1806
+
1807
+ const { include } = await buildAccessScopedInclude(
1808
+ { terms: { where: { from_Bill_term: { some: { amount: { gt: 5 } } } } } },
1809
+ config.lists.Company.fields,
1810
+ { session: null, context: makeContext() },
1811
+ config,
1812
+ 'Company',
1813
+ )
1814
+
1815
+ expect(include).toEqual({
1816
+ terms: {
1817
+ where: {
1818
+ from_Bill_term: {
1819
+ some: { AND: [{ paid: { equals: true } }, { amount: { gt: 5 } }] },
1820
+ },
1821
+ },
1822
+ },
1823
+ })
1824
+ })
1825
+
1826
+ it('throws when a field nested under a synthetic quantifier is denied by field-level read access', async () => {
1827
+ const config = syntheticNestedConfig()
1828
+ ;(config.lists.Bill.fields as Record<string, FieldConfig>).secretAmount = {
1829
+ type: 'integer',
1830
+ access: { read: () => false },
1831
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal field config for unit test
1832
+ } as any as FieldConfig
1833
+
1834
+ await expect(
1835
+ buildAccessScopedInclude(
1836
+ { terms: { where: { from_Bill_term: { some: { secretAmount: { gt: 5 } } } } } },
1837
+ config.lists.Company.fields,
1838
+ { session: null, context: makeContext() },
1839
+ config,
1840
+ 'Company',
1841
+ ),
1842
+ ).rejects.toThrow(/"secretAmount" is denied by field-level read access/)
1843
+ })
1844
+
1845
+ it("throws when the synthetic's own SOURCE list denies query access outright, matching #916's loud failure", async () => {
1846
+ const config = syntheticNestedConfig()
1847
+ config.lists.Bill.access = { operation: { query: () => false } }
1848
+
1849
+ await expect(
1850
+ buildAccessScopedInclude(
1851
+ { terms: { where: { from_Bill_term: { some: { amount: { gt: 5 } } } } } },
1852
+ config.lists.Company.fields,
1853
+ { session: null, context: makeContext() },
1854
+ config,
1855
+ 'Company',
1856
+ ),
1857
+ ).rejects.toThrow(RelationFilterAccessDeniedError)
1858
+ })
1859
+ })
1860
+
1861
+ describe("a to-one relation's `is: null`/`isNot: null` inside a nested quantifier (Codex review finding on #1108)", () => {
1862
+ // Root -> Parent (to-many, unscoped) -> Owner (to-one, query-scoped by a
1863
+ // filter). Parent's own access is `true` (no filter), so the outer
1864
+ // AND-fold is a no-op and cannot mask what this test is isolating: the
1865
+ // fold that happens INSIDE the quantifier's own value.
1866
+ function nullQuantifierConfig(): OpenSaasConfig {
1867
+ return {
1868
+ db: { provider: 'sqlite' },
1869
+ lists: {
1870
+ Root: {
1871
+ fields: { name: { type: 'text' } as FieldConfig, parent: rel('Parent', true) },
1872
+ access: { operation: { query: () => true } },
1873
+ },
1874
+ Parent: {
1875
+ fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
1876
+ access: { operation: { query: () => true } },
1877
+ },
1878
+ Owner: {
1879
+ fields: { name: { type: 'text' } as FieldConfig },
1880
+ access: { operation: { query: () => ({ secret: { equals: true } }) } },
1881
+ },
1882
+ },
1883
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1884
+ } as any as OpenSaasConfig
1885
+ }
1886
+
1887
+ it('preserves `is: null` rather than inverting it into an access-filter match', async () => {
1888
+ // Before this fix, folding the access filter into a quantifier's
1889
+ // value unconditionally turned `is: null` ("has no related row") into
1890
+ // `is: <accessWhere>` ("has a related row matching the filter") —
1891
+ // the opposite of the caller's own predicate.
1892
+ const config = nullQuantifierConfig()
1893
+
1894
+ const { include } = await buildAccessScopedInclude(
1895
+ { parent: { where: { owner: { is: null } } } },
1896
+ config.lists.Root.fields,
1897
+ { session: null, context: makeContext() },
1898
+ config,
1899
+ 'Root',
1900
+ )
1901
+
1902
+ expect(include).toEqual({ parent: { where: { owner: { is: null } } } })
1903
+ })
1904
+
1905
+ it('preserves `isNot: null` the same way', async () => {
1906
+ const config = nullQuantifierConfig()
1907
+
1908
+ const { include } = await buildAccessScopedInclude(
1909
+ { parent: { where: { owner: { isNot: null } } } },
1910
+ config.lists.Root.fields,
1911
+ { session: null, context: makeContext() },
1912
+ config,
1913
+ 'Root',
1914
+ )
1915
+
1916
+ expect(include).toEqual({ parent: { where: { owner: { isNot: null } } } })
1917
+ })
1918
+ })
1919
+ })