@opensaas/stack-core 0.40.0 → 0.41.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 +39 -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 +22 -22
  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 +22 -22
  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
@@ -21,7 +21,7 @@ export type FieldResolveInputHookArgs<
21
21
  inputData: TTypeInfo['inputs']['create']
22
22
  item: undefined
23
23
  resolvedData: TTypeInfo['inputs']['create']
24
- context: import('../access/types.js').AccessContext
24
+ context: import('../context/index.js').StackContext
25
25
  }
26
26
  | {
27
27
  listKey: string
@@ -30,7 +30,7 @@ export type FieldResolveInputHookArgs<
30
30
  inputData: TTypeInfo['inputs']['update']
31
31
  item: TTypeInfo['item']
32
32
  resolvedData: TTypeInfo['inputs']['update']
33
- context: import('../access/types.js').AccessContext
33
+ context: import('../context/index.js').StackContext
34
34
  }
35
35
 
36
36
  /** Arguments for {@link FieldHooks.validate} (and its deprecated `validateInput` alias). */
@@ -45,7 +45,7 @@ export type FieldValidateHookArgs<
45
45
  inputData: TTypeInfo['inputs']['create']
46
46
  item: undefined
47
47
  resolvedData: TTypeInfo['inputs']['create']
48
- context: import('../access/types.js').AccessContext
48
+ context: import('../context/index.js').StackContext
49
49
  addValidationError: (msg: string) => void
50
50
  }
51
51
  | {
@@ -55,7 +55,7 @@ export type FieldValidateHookArgs<
55
55
  inputData: TTypeInfo['inputs']['update']
56
56
  item: TTypeInfo['item']
57
57
  resolvedData: TTypeInfo['inputs']['update']
58
- context: import('../access/types.js').AccessContext
58
+ context: import('../context/index.js').StackContext
59
59
  addValidationError: (msg: string) => void
60
60
  }
61
61
  | {
@@ -63,7 +63,7 @@ export type FieldValidateHookArgs<
63
63
  fieldKey: TFieldKey
64
64
  operation: 'delete'
65
65
  item: TTypeInfo['item']
66
- context: import('../access/types.js').AccessContext
66
+ context: import('../context/index.js').StackContext
67
67
  addValidationError: (msg: string) => void
68
68
  }
69
69
 
@@ -78,7 +78,7 @@ export type FieldBeforeOperationHookArgs<
78
78
  operation: 'create'
79
79
  inputData: TTypeInfo['inputs']['create']
80
80
  resolvedData: TTypeInfo['inputs']['create']
81
- context: import('../access/types.js').AccessContext
81
+ context: import('../context/index.js').StackContext
82
82
  }
83
83
  | {
84
84
  listKey: string
@@ -87,14 +87,14 @@ export type FieldBeforeOperationHookArgs<
87
87
  inputData: TTypeInfo['inputs']['update']
88
88
  item: TTypeInfo['item']
89
89
  resolvedData: TTypeInfo['inputs']['update']
90
- context: import('../access/types.js').AccessContext
90
+ context: import('../context/index.js').StackContext
91
91
  }
92
92
  | {
93
93
  listKey: string
94
94
  fieldKey: TFieldKey
95
95
  operation: 'delete'
96
96
  item: TTypeInfo['item']
97
- context: import('../access/types.js').AccessContext
97
+ context: import('../context/index.js').StackContext
98
98
  }
99
99
 
100
100
  /** Arguments for {@link FieldHooks.afterOperation}. */
@@ -109,7 +109,7 @@ export type FieldAfterOperationHookArgs<
109
109
  inputData: TTypeInfo['inputs']['create']
110
110
  item: TTypeInfo['item']
111
111
  resolvedData: TTypeInfo['inputs']['create']
112
- context: import('../access/types.js').AccessContext
112
+ context: import('../context/index.js').StackContext
113
113
  }
114
114
  | {
115
115
  listKey: string
@@ -119,14 +119,14 @@ export type FieldAfterOperationHookArgs<
119
119
  originalItem: TTypeInfo['item']
120
120
  item: TTypeInfo['item']
121
121
  resolvedData: TTypeInfo['inputs']['update']
122
- context: import('../access/types.js').AccessContext
122
+ context: import('../context/index.js').StackContext
123
123
  }
124
124
  | {
125
125
  listKey: string
126
126
  fieldKey: TFieldKey
127
127
  operation: 'delete'
128
128
  originalItem: TTypeInfo['item']
129
- context: import('../access/types.js').AccessContext
129
+ context: import('../context/index.js').StackContext
130
130
  }
131
131
 
132
132
  /**
@@ -1609,7 +1609,7 @@ export type ResolveInputHookArgs<
1609
1609
  inputData: TCreateInput
1610
1610
  resolvedData: TCreateInput
1611
1611
  item: undefined
1612
- context: import('../access/types.js').AccessContext
1612
+ context: import('../context/index.js').StackContext
1613
1613
  }
1614
1614
  | {
1615
1615
  listKey: string
@@ -1617,7 +1617,7 @@ export type ResolveInputHookArgs<
1617
1617
  inputData: TUpdateInput
1618
1618
  resolvedData: TUpdateInput
1619
1619
  item: TOutput
1620
- context: import('../access/types.js').AccessContext
1620
+ context: import('../context/index.js').StackContext
1621
1621
  }
1622
1622
 
1623
1623
  /**
@@ -1637,7 +1637,7 @@ export type ValidateHookArgs<
1637
1637
  inputData: TCreateInput
1638
1638
  resolvedData: TCreateInput
1639
1639
  item: undefined
1640
- context: import('../access/types.js').AccessContext
1640
+ context: import('../context/index.js').StackContext
1641
1641
  addValidationError: (msg: string) => void
1642
1642
  }
1643
1643
  | {
@@ -1646,14 +1646,14 @@ export type ValidateHookArgs<
1646
1646
  inputData: TUpdateInput
1647
1647
  resolvedData: TUpdateInput
1648
1648
  item: TOutput
1649
- context: import('../access/types.js').AccessContext
1649
+ context: import('../context/index.js').StackContext
1650
1650
  addValidationError: (msg: string) => void
1651
1651
  }
1652
1652
  | {
1653
1653
  listKey: string
1654
1654
  operation: 'delete'
1655
1655
  item: TOutput
1656
- context: import('../access/types.js').AccessContext
1656
+ context: import('../context/index.js').StackContext
1657
1657
  addValidationError: (msg: string) => void
1658
1658
  }
1659
1659
 
@@ -1673,7 +1673,7 @@ export type BeforeOperationHookArgs<
1673
1673
  operation: 'create'
1674
1674
  inputData: TCreateInput
1675
1675
  resolvedData: TCreateInput
1676
- context: import('../access/types.js').AccessContext
1676
+ context: import('../context/index.js').StackContext
1677
1677
  }
1678
1678
  | {
1679
1679
  listKey: string
@@ -1681,13 +1681,13 @@ export type BeforeOperationHookArgs<
1681
1681
  inputData: TUpdateInput
1682
1682
  item: TOutput
1683
1683
  resolvedData: TUpdateInput
1684
- context: import('../access/types.js').AccessContext
1684
+ context: import('../context/index.js').StackContext
1685
1685
  }
1686
1686
  | {
1687
1687
  listKey: string
1688
1688
  operation: 'delete'
1689
1689
  item: TOutput
1690
- context: import('../access/types.js').AccessContext
1690
+ context: import('../context/index.js').StackContext
1691
1691
  }
1692
1692
 
1693
1693
  /**
@@ -1707,7 +1707,7 @@ export type AfterOperationHookArgs<
1707
1707
  inputData: TCreateInput
1708
1708
  item: TOutput
1709
1709
  resolvedData: TCreateInput
1710
- context: import('../access/types.js').AccessContext
1710
+ context: import('../context/index.js').StackContext
1711
1711
  }
1712
1712
  | {
1713
1713
  listKey: string
@@ -1716,13 +1716,13 @@ export type AfterOperationHookArgs<
1716
1716
  originalItem: TOutput
1717
1717
  item: TOutput
1718
1718
  resolvedData: TUpdateInput
1719
- context: import('../access/types.js').AccessContext
1719
+ context: import('../context/index.js').StackContext
1720
1720
  }
1721
1721
  | {
1722
1722
  listKey: string
1723
1723
  operation: 'delete'
1724
1724
  originalItem: TOutput
1725
- context: import('../access/types.js').AccessContext
1725
+ context: import('../context/index.js').StackContext
1726
1726
  }
1727
1727
 
1728
1728
  /**
@@ -1,5 +1,5 @@
1
1
  import type { ListConfig } from '../config/types.js'
2
- import type { AccessContext } from '../access/types.js'
2
+ import type { StackContext } from './index.js'
3
3
  import {
4
4
  executeResolveInput,
5
5
  executeValidate,
@@ -25,7 +25,9 @@ export interface HookPipelineArgs {
25
25
  listConfig: ListConfig<any>
26
26
  inputData: Record<string, unknown>
27
27
  item: Record<string, unknown> | undefined
28
- context: AccessContext
28
+ // #1176: the full secured context (sudo/withSession/transaction), bound to
29
+ // the write's transaction client — see `bindContextToTransaction`.
30
+ context: StackContext
29
31
  }
30
32
 
31
33
  export interface HookPipelineResult {
@@ -12,8 +12,13 @@ import {
12
12
  validateQueryFieldReadAccess,
13
13
  resolveToOneAccessVisibility,
14
14
  emptyToOneAccessFilterTree,
15
+ emptyCountAccessDenialTree,
16
+ } from '../access/index.js'
17
+ import type {
18
+ DeclaredOnlyTree,
19
+ ToOneAccessFilterTree,
20
+ CountAccessDenialTree,
15
21
  } from '../access/index.js'
16
- import type { DeclaredOnlyTree, ToOneAccessFilterTree } from '../access/index.js'
17
22
  import { ValidationError, DatabaseError } from '../hooks/index.js'
18
23
  import { getDbKey } from '../lib/case-utils.js'
19
24
  import { uniqueConstraintOf } from '../lib/prisma-errors.js'
@@ -359,6 +364,15 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
359
364
  */
360
365
  withSession: (session: Session | null) => StackContext<TPrisma>
361
366
  _isSudo: boolean
367
+ /**
368
+ * @internal Present so a hook-bound `StackContext` (issue #1176) satisfies
369
+ * {@link AccessContext} and can be threaded through the same internal
370
+ * write-pipeline/access plumbing that a plain `AccessContext` is — see
371
+ * `_resolveOutputChain` on `AccessContext` for what this tracks.
372
+ */
373
+ _resolveOutputChain: readonly { listKey: string; fieldKey: string }[]
374
+ /** @internal See `_transactionOwner` on `AccessContext`. */
375
+ _transactionOwner?: TransactionRegistry
362
376
  }
363
377
 
364
378
  /**
@@ -406,6 +420,11 @@ export function getContext<
406
420
  // owner's callback body, carry the deferral registry so writes reached
407
421
  // through this context join it instead of firing afterTransaction eagerly.
408
422
  _transactionOwner?: TransactionRegistry,
423
+ // Internal (ADR-0023, issue #1176): when rebuilding the context for a
424
+ // hook-bound write (or a derived `sudo()`/`withSession()` of one), carry the
425
+ // resolve chain forward so a write issued from inside a `resolveOutput` hook
426
+ // keeps that hook's cycle-guard chain instead of resetting to empty.
427
+ _resolveOutputChain?: readonly { listKey: string; fieldKey: string }[],
409
428
  ): StackContext<TPrisma> {
410
429
  // Broad type to allow dynamic model access; populated by populateDbDelegate below.
411
430
  const db: Record<string, unknown> = {}
@@ -440,7 +459,7 @@ export function getContext<
440
459
  // client, otherwise start empty and populate via plugin runtimes below.
441
460
  plugins: _sharedPlugins ?? {},
442
461
  _isSudo,
443
- _resolveOutputChain: [],
462
+ _resolveOutputChain: _resolveOutputChain ?? [],
444
463
  _transactionOwner,
445
464
  }
446
465
 
@@ -791,6 +810,10 @@ export function getContext<
791
810
  // ADR-0028: a sudo write issued from inside an owned transaction (e.g.
792
811
  // `tx.sudo().db.x.create()`) must still defer to that owner.
793
812
  context._transactionOwner,
813
+ // #1176: carry the resolve chain forward so a `context.sudo()` called
814
+ // from inside a `resolveOutput` hook keeps that hook's cycle-guard
815
+ // chain (ADR-0023) rather than resetting to empty.
816
+ context._resolveOutputChain,
794
817
  )
795
818
  }
796
819
 
@@ -807,6 +830,8 @@ export function getContext<
807
830
  // ADR-0028: a write issued from inside an owned transaction (e.g.
808
831
  // `tx.withSession(s).db.x.create()`) must still defer to that owner.
809
832
  context._transactionOwner,
833
+ // #1176: see the identical comment in `sudo()` above.
834
+ context._resolveOutputChain,
810
835
  )
811
836
  }
812
837
 
@@ -847,6 +872,7 @@ export function getContext<
847
872
  _isSudo,
848
873
  context.plugins,
849
874
  registry,
875
+ context._resolveOutputChain,
850
876
  ),
851
877
  )
852
878
  : (client.$transaction(
@@ -860,6 +886,7 @@ export function getContext<
860
886
  _isSudo,
861
887
  context.plugins,
862
888
  registry,
889
+ context._resolveOutputChain,
863
890
  ),
864
891
  ),
865
892
  options,
@@ -879,6 +906,12 @@ export function getContext<
879
906
  withSession,
880
907
  transaction,
881
908
  _isSudo,
909
+ // #1176: carried so a `StackContext` structurally satisfies `AccessContext`
910
+ // and can be handed, unchanged, to the internal write-pipeline/hook
911
+ // plumbing that a plain `AccessContext` was built for — see
912
+ // `bindContextToTransaction` in `write-pipeline.ts`.
913
+ _resolveOutputChain: context._resolveOutputChain,
914
+ _transactionOwner: context._transactionOwner,
882
915
  }
883
916
  return returned
884
917
  }
@@ -955,11 +988,12 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
955
988
  * caller include / bare (ADR-0024) — while folding declared dependencies
956
989
  * (`needs`, ADR-0025) into whichever of those the read is already using.
957
990
  *
958
- * A fragment's own `include` and a sudo caller's `include` are folded and
959
- * used as-is, matching their existing (unmerged) treatment. A non-sudo
960
- * caller include is folded and then scoped by `buildAccessScopedInclude`
961
- * (ADR-0026) caller-directed, so a relation named nowhere in the folded
962
- * tree never has its list's `query` access evaluated at all. A bare read
991
+ * A non-sudo fragment's own `include` and a non-sudo caller's `include` are
992
+ * both folded and then scoped by `buildAccessScopedInclude` (ADR-0026)
993
+ * caller-directed, so a relation named nowhere in the folded tree never has
994
+ * its list's `query` access evaluated at all (issue #1088: a fragment read
995
+ * used to skip this walk entirely). A sudo caller's `include` (fragment or
996
+ * not) is folded and used as-is, unscoped — sudo is unaffected. A bare read
963
997
  * stays on the exact ADR-0024 path — `include: undefined`, no related
964
998
  * `query` access evaluated — unless folding actually added something, which
965
999
  * only happens when a field on this list declares `needs`.
@@ -973,9 +1007,10 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
973
1007
  *
974
1008
  * Also returns `toOneAccessFilters` — the to-one relations `buildAccessScopedInclude`
975
1009
  * flagged as needing a post-query existence check rather than a Prisma-side
976
- * `where` (issue #974). Only the non-sudo caller-include path can produce a
977
- * non-empty tree: it's the only path that evaluates a related list's `query`
978
- * access at all. The fragment and sudo paths always return an empty tree.
1010
+ * `where` (issue #974). Only a non-sudo fragment or caller-include read can
1011
+ * produce a non-empty tree: those are the only paths that evaluate a related
1012
+ * list's `query` access at all. A sudo read and a bare read always return an
1013
+ * empty tree.
979
1014
  */
980
1015
  async function resolveReadInclude(
981
1016
  callerInclude: Record<string, unknown> | undefined,
@@ -990,6 +1025,7 @@ async function resolveReadInclude(
990
1025
  declaredOnly: DeclaredOnlyTree
991
1026
  selection: FieldSelectionScope | undefined
992
1027
  toOneAccessFilters: ToOneAccessFilterTree
1028
+ countDenials: CountAccessDenialTree
993
1029
  }> {
994
1030
  if (fragmentFields !== undefined) {
995
1031
  const fragmentInclude = buildInclude(fragmentFields) ?? undefined
@@ -1002,27 +1038,66 @@ async function resolveReadInclude(
1002
1038
  [listName],
1003
1039
  selection,
1004
1040
  )
1005
- return { ...folded, selection, toOneAccessFilters: emptyToOneAccessFilterTree() }
1041
+
1042
+ if (context._isSudo || !folded.include) {
1043
+ return {
1044
+ ...folded,
1045
+ selection,
1046
+ toOneAccessFilters: emptyToOneAccessFilterTree(),
1047
+ countDenials: emptyCountAccessDenialTree(),
1048
+ }
1049
+ }
1050
+
1051
+ const { include, toOneAccessFilters, countDenials } = await buildAccessScopedInclude(
1052
+ folded.include,
1053
+ listConfig.fields,
1054
+ { session: context.session, context },
1055
+ config,
1056
+ listName,
1057
+ )
1058
+ return {
1059
+ include,
1060
+ declaredOnly: folded.declaredOnly,
1061
+ selection,
1062
+ toOneAccessFilters,
1063
+ countDenials,
1064
+ }
1006
1065
  }
1007
1066
 
1008
1067
  if (context._isSudo) {
1009
1068
  const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
1010
- return { ...folded, selection: undefined, toOneAccessFilters: emptyToOneAccessFilterTree() }
1069
+ return {
1070
+ ...folded,
1071
+ selection: undefined,
1072
+ toOneAccessFilters: emptyToOneAccessFilterTree(),
1073
+ countDenials: emptyCountAccessDenialTree(),
1074
+ }
1011
1075
  }
1012
1076
 
1013
1077
  const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
1014
1078
  if (!folded.include) {
1015
- return { ...folded, selection: undefined, toOneAccessFilters: emptyToOneAccessFilterTree() }
1079
+ return {
1080
+ ...folded,
1081
+ selection: undefined,
1082
+ toOneAccessFilters: emptyToOneAccessFilterTree(),
1083
+ countDenials: emptyCountAccessDenialTree(),
1084
+ }
1016
1085
  }
1017
1086
 
1018
- const { include, toOneAccessFilters } = await buildAccessScopedInclude(
1087
+ const { include, toOneAccessFilters, countDenials } = await buildAccessScopedInclude(
1019
1088
  folded.include,
1020
1089
  listConfig.fields,
1021
1090
  { session: context.session, context },
1022
1091
  config,
1023
1092
  listName,
1024
1093
  )
1025
- return { include, declaredOnly: folded.declaredOnly, selection: undefined, toOneAccessFilters }
1094
+ return {
1095
+ include,
1096
+ declaredOnly: folded.declaredOnly,
1097
+ selection: undefined,
1098
+ toOneAccessFilters,
1099
+ countDenials,
1100
+ }
1026
1101
  }
1027
1102
 
1028
1103
  function createFindUnique<TPrisma extends PrismaClientLike>(
@@ -1079,14 +1154,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1079
1154
  // Resolve `include`, folding any declared dependencies (`needs`,
1080
1155
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1081
1156
  // already produces — see `resolveReadInclude`'s doc comment.
1082
- let { include, declaredOnly, selection, toOneAccessFilters } = await resolveReadInclude(
1083
- args.include,
1084
- fragment ? fragment._fields : undefined,
1085
- listName,
1086
- listConfig,
1087
- context,
1088
- config,
1089
- )
1157
+ let { include, declaredOnly, selection, toOneAccessFilters, countDenials } =
1158
+ await resolveReadInclude(
1159
+ args.include,
1160
+ fragment ? fragment._fields : undefined,
1161
+ listName,
1162
+ listConfig,
1163
+ context,
1164
+ config,
1165
+ )
1090
1166
 
1091
1167
  // Virtual fields have no database column. Whichever path produced
1092
1168
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
@@ -1131,6 +1207,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1131
1207
  declaredOnly,
1132
1208
  selection,
1133
1209
  toOneVisibility,
1210
+ countDenials,
1134
1211
  )
1135
1212
 
1136
1213
  if (fragment) {
@@ -1240,14 +1317,15 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1240
1317
  // Resolve `include`, folding any declared dependencies (`needs`,
1241
1318
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1242
1319
  // already produces — see `resolveReadInclude`'s doc comment.
1243
- let { include, declaredOnly, selection, toOneAccessFilters } = await resolveReadInclude(
1244
- args?.include,
1245
- fragment ? fragment._fields : undefined,
1246
- listName,
1247
- listConfig,
1248
- context,
1249
- config,
1250
- )
1320
+ let { include, declaredOnly, selection, toOneAccessFilters, countDenials } =
1321
+ await resolveReadInclude(
1322
+ args?.include,
1323
+ fragment ? fragment._fields : undefined,
1324
+ listName,
1325
+ listConfig,
1326
+ context,
1327
+ config,
1328
+ )
1251
1329
 
1252
1330
  // Strips virtual keys from `include` before the Prisma call — see the
1253
1331
  // `createFindUnique` comment above for why (#628, ADR-0027).
@@ -1289,6 +1367,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1289
1367
  declaredOnly,
1290
1368
  selection,
1291
1369
  toOneVisibility,
1370
+ countDenials,
1292
1371
  ),
1293
1372
  ),
1294
1373
  )
@@ -1573,14 +1652,15 @@ function createGet<TPrisma extends PrismaClientLike>(
1573
1652
  // Resolve `include`, folding any declared dependencies (`needs`,
1574
1653
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1575
1654
  // already produces — see `resolveReadInclude`'s doc comment.
1576
- let { include, declaredOnly, selection, toOneAccessFilters } = await resolveReadInclude(
1577
- args?.include,
1578
- fragment ? fragment._fields : undefined,
1579
- listName,
1580
- listConfig,
1581
- context,
1582
- config,
1583
- )
1655
+ let { include, declaredOnly, selection, toOneAccessFilters, countDenials } =
1656
+ await resolveReadInclude(
1657
+ args?.include,
1658
+ fragment ? fragment._fields : undefined,
1659
+ listName,
1660
+ listConfig,
1661
+ context,
1662
+ config,
1663
+ )
1584
1664
 
1585
1665
  // Virtual fields have no database column and must never reach Prisma (#628).
1586
1666
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
@@ -1611,6 +1691,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1611
1691
  declaredOnly,
1612
1692
  selection,
1613
1693
  toOneVisibility,
1694
+ countDenials,
1614
1695
  )
1615
1696
  if (fragment) {
1616
1697
  return pickFields(filtered, fragment._fields)
@@ -1,10 +1,12 @@
1
1
  import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
2
- import type { AccessContext, FieldAccess } from '../access/types.js'
2
+ import type { FieldAccess, PrismaFilter } from '../access/types.js'
3
+ import type { StackContext } from './index.js'
3
4
  import {
4
5
  checkAccess,
5
6
  checkCreateAccess,
6
7
  filterWritableFields,
7
8
  getRelatedListConfig,
9
+ mergeFilters,
8
10
  resolveSyntheticReverseRelation,
9
11
  } from '../access/index.js'
10
12
  import { checkFieldAccess } from '../access/field-access.js'
@@ -199,7 +201,7 @@ async function processNestedCreate(
199
201
  relatedListName: string,
200
202
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
201
203
  relatedListConfig: ListConfig<any>,
202
- context: AccessContext,
204
+ context: StackContext,
203
205
  config: OpenSaasConfig,
204
206
  prisma: unknown,
205
207
  afterTasks: AfterTask[],
@@ -391,7 +393,7 @@ async function verifyConnectReachable(
391
393
  relatedListName: string,
392
394
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
393
395
  relatedListConfig: ListConfig<any>,
394
- context: AccessContext,
396
+ context: StackContext,
395
397
  prisma: unknown,
396
398
  owningFieldAccess: FieldAccess | undefined,
397
399
  enclosingOperation: 'create' | 'update',
@@ -456,7 +458,7 @@ async function processNestedConnect(
456
458
  relatedListName: string,
457
459
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
458
460
  relatedListConfig: ListConfig<any>,
459
- context: AccessContext,
461
+ context: StackContext,
460
462
  prisma: unknown,
461
463
  owningFieldAccess: FieldAccess | undefined,
462
464
  enclosingOperation: 'create' | 'update',
@@ -484,6 +486,28 @@ async function processNestedConnect(
484
486
  return connections
485
487
  }
486
488
 
489
+ /**
490
+ * Re-check a nested update/delete access result against the target row,
491
+ * mirroring the Write Pipeline's `resolveExistingTarget` (#1081): `false`
492
+ * denies, `true` allows outright (the row's existence is already established
493
+ * by the caller's own `findUnique`), and a returned PrismaFilter must
494
+ * additionally match the row, re-checked in the DATABASE via
495
+ * `findFirst(mergeFilters(where, accessResult))` rather than in memory — the
496
+ * same requirement `connect`'s reachability check already applies (#578), so
497
+ * nested-relation predicates and boolean combinators are honoured correctly.
498
+ */
499
+ async function isExistingTargetAccessible(
500
+ model: { findFirst: (args: { where: Record<string, unknown> }) => Promise<unknown> },
501
+ where: Record<string, unknown>,
502
+ accessResult: boolean | PrismaFilter,
503
+ ): Promise<boolean> {
504
+ if (accessResult === false) return false
505
+ if (accessResult === true) return true
506
+
507
+ const matchesFilter = await model.findFirst({ where: mergeFilters(where, accessResult) ?? {} })
508
+ return matchesFilter !== null
509
+ }
510
+
487
511
  /**
488
512
  * Process nested update operations.
489
513
  *
@@ -497,7 +521,7 @@ async function processNestedUpdate(
497
521
  relatedListName: string,
498
522
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
499
523
  relatedListConfig: ListConfig<any>,
500
- context: AccessContext,
524
+ context: StackContext,
501
525
  config: OpenSaasConfig,
502
526
  prisma: unknown,
503
527
  afterTasks: AfterTask[],
@@ -529,7 +553,7 @@ async function processNestedUpdate(
529
553
  context,
530
554
  })
531
555
 
532
- if (accessResult === false) {
556
+ if (!(await isExistingTargetAccessible(model, where, accessResult))) {
533
557
  throw new Error('Access denied: Cannot update related item')
534
558
  }
535
559
  }
@@ -693,7 +717,7 @@ async function processNestedDelete(
693
717
  relatedListName: string,
694
718
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
695
719
  relatedListConfig: ListConfig<any>,
696
- context: AccessContext,
720
+ context: StackContext,
697
721
  prisma: unknown,
698
722
  afterTasks: AfterTask[],
699
723
  ): Promise<Record<string, unknown> | Array<Record<string, unknown>> | boolean> {
@@ -726,7 +750,7 @@ async function processNestedDelete(
726
750
  context,
727
751
  })
728
752
 
729
- if (accessResult === false) {
753
+ if (!(await isExistingTargetAccessible(model, where, accessResult))) {
730
754
  throw new Error('Access denied: Cannot delete related item')
731
755
  }
732
756
  }
@@ -801,7 +825,7 @@ async function processNestedConnectOrCreate(
801
825
  relatedListName: string,
802
826
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
803
827
  relatedListConfig: ListConfig<any>,
804
- context: AccessContext,
828
+ context: StackContext,
805
829
  config: OpenSaasConfig,
806
830
  prisma: unknown,
807
831
  afterTasks: AfterTask[],
@@ -922,7 +946,7 @@ interface NestedOpHandlerArgs {
922
946
  enclosingItem: Record<string, unknown> | undefined
923
947
  /** The enclosing write's input data, passed to the owning-field gate for the same reason as `enclosingItem`. */
924
948
  enclosingInputData: Record<string, unknown> | undefined
925
- context: AccessContext
949
+ context: StackContext
926
950
  config: OpenSaasConfig
927
951
  /** Prisma client used for dynamic model access during access checks. */
928
952
  prisma: unknown
@@ -1190,7 +1214,7 @@ export async function processNestedOperations(
1190
1214
  data: Record<string, unknown>,
1191
1215
  fieldConfigs: Record<string, FieldConfig>,
1192
1216
  config: OpenSaasConfig,
1193
- context: AccessContext & { prisma: unknown },
1217
+ context: StackContext & { prisma: unknown },
1194
1218
  operation: 'create' | 'update',
1195
1219
  parentListName: string,
1196
1220
  parentOriginalItem: Record<string, unknown> | undefined,