@opensaas/stack-core 0.37.0 → 0.39.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 (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -4,17 +4,19 @@ import {
4
4
  checkAccess,
5
5
  mergeFilters,
6
6
  filterReadableFields,
7
- buildIncludeWithAccessControl,
8
- mergeIncludeWithAccessControl,
7
+ buildAccessScopedInclude,
8
+ buildAccessScopedWhere,
9
9
  stripVirtualFieldsFromInclude,
10
10
  foldDeclaredDependencies,
11
+ validateQueryKeys,
12
+ validateQueryFieldReadAccess,
11
13
  } from '../access/index.js'
12
14
  import type { DeclaredOnlyTree } from '../access/index.js'
13
15
  import { ValidationError, DatabaseError } from '../hooks/index.js'
14
16
  import { getDbKey } from '../lib/case-utils.js'
15
17
  import type { PrismaClientLike } from '../access/types.js'
16
- import { buildInclude, pickFields, isFragment } from '../query/index.js'
17
- import type { FieldSelection } from '../query/index.js'
18
+ import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
19
+ import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
18
20
  import { getRelationshipOptions } from '../query/relationship-options.js'
19
21
  import {
20
22
  runWritePipeline,
@@ -22,29 +24,31 @@ import {
22
24
  updateWriteStrategy,
23
25
  deleteWriteStrategy,
24
26
  } from './write-pipeline.js'
27
+ import { AfterTransactionError } from './transaction-boundary.js'
28
+ import { TransactionRegistry } from '../access/transaction-registry.js'
29
+ import type { TransactionSettleOutcome } from '../access/transaction-registry.js'
25
30
 
26
31
  export type ServerActionProps =
27
32
  | { listKey: string; action: 'create'; data: Record<string, unknown> }
28
33
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
29
34
  | { listKey: string; action: 'delete'; id: string }
30
35
  | { listKey: string; action: 'bulkDelete'; ids: string[] }
31
- // Custom list-specific Bulk action (issue #736). `key` names an action
32
- // declared in the list's `ui.listView.bulkActions`; the client only ever
33
- // sends this serialisable `{ key, ids }` the server-side `handler`
34
- // (never serialised) is looked up by `key` and run with the rebuilt secured
35
- // context over `ids`. Returns a distinct `{ bulkAction, message? }` shape so
36
- // a redirect-on-`success` wrapper never hijacks it (same rationale as
37
- // `bulkDelete`).
36
+ // Custom list-specific bulk action (issue #736). `key` names an action
37
+ // declared in the list's `ui.listView.bulkActions`; the client sends only
38
+ // this serialisable `{ key, ids }` and the server-side `handler` (never
39
+ // serialised) is looked up by `key`. Returns a distinct `{ bulkAction,
40
+ // message? }` shape, never `success`, so a redirect-on-`success` wrapper
41
+ // does not hijack it.
38
42
  | { listKey: string; action: 'bulkAction'; key: string; ids: string[] }
39
43
  // Relationship-table row removal (ADR-0018, #739). `listKey`/`id` target the
40
- // RELATED row, so the related list's own access control + hooks apply (never
41
- // the parent's). `mode: 'disconnect'` unlinks the row non-destructively by
42
- // disconnecting its back-reference (`field`; `parentId` is the record being
43
- // edited, needed only when that back-reference is to-many, e.g. a
44
- // many-to-many join); `mode: 'delete'` truly deletes the row. Like
45
- // `bulkDelete`, it returns a distinct `{ removed }` shape (never a
46
- // single-op `success`) so a UI wrapper that redirects on `success` the
47
- // item-form pattern — does not hijack an in-place row removal.
44
+ // RELATED row, so the related list's own access control and hooks apply
45
+ // never the parent's. The other relationship-table actions below share this
46
+ // boundary. `mode: 'disconnect'` unlinks the row by disconnecting its
47
+ // back-reference (`field`; `parentId` is needed only when that back-reference
48
+ // is to-many, e.g. a many-to-many join) without deleting it; `mode: 'delete'`
49
+ // truly deletes the row. Returns a distinct `{ removed }` shape, never
50
+ // `success`, so a UI wrapper that redirects on `success` does not hijack an
51
+ // in-place row removal.
48
52
  | {
49
53
  listKey: string
50
54
  action: 'removeRelated'
@@ -53,13 +57,10 @@ export type ServerActionProps =
53
57
  field?: string
54
58
  parentId?: string
55
59
  }
56
- // Relationship-table inline cell edit (issue #737). `listKey`/`id` target the
57
- // RELATED row and `field`/`value` a single scalar field on it, so the update
58
- // runs through the related list's OWN operation- and field-level access +
59
- // hooks/validation (never the parent's) the same ADR-0018 boundary as
60
- // `removeRelated`. It returns a distinct `{ updated }` shape (never a
61
- // single-op `success`) so a UI wrapper that redirects on `success` — the
62
- // item-form pattern — cannot hijack an in-place cell edit.
60
+ // Relationship-table inline cell edit (issue #737); same ADR-0018 boundary
61
+ // as `removeRelated` `listKey`/`id` target the RELATED row. Returns a
62
+ // distinct `{ updated }` shape, never `success`, so a UI wrapper that
63
+ // redirects on `success` does not hijack an in-place cell edit.
63
64
  | {
64
65
  listKey: string
65
66
  action: 'updateRelated'
@@ -67,14 +68,12 @@ export type ServerActionProps =
67
68
  field: string
68
69
  value: unknown
69
70
  }
70
- // Relationship-table pre-linked create (issue #738). `listKey` targets the
71
- // RELATED list, so the related list's own create access control + hooks apply
72
- // (never the parent's) the same ADR-0018 boundary as `removeRelated`. The
73
- // back-reference to the parent is set on the SERVER from `field`/`parentId`
74
- // (never trusted from `data`), so the new row is linked to exactly the parent
75
- // being edited. It returns a distinct `{ created }` shape (never a single-op
76
- // `success`) so a UI wrapper that redirects on `success` — the item-form
77
- // pattern — does not hijack an in-place create.
71
+ // Relationship-table pre-linked create (issue #738); same ADR-0018 boundary
72
+ // as `removeRelated` `listKey` targets the RELATED list. The back-reference
73
+ // to the parent is set on the SERVER from `field`/`parentId`, never trusted
74
+ // from `data`, so a hostile client cannot re-target the link. Returns a
75
+ // distinct `{ created }` shape, never `success`, so a UI wrapper that
76
+ // redirects on `success` does not hijack an in-place create.
78
77
  | {
79
78
  listKey: string
80
79
  action: 'createRelated'
@@ -91,21 +90,11 @@ export type ServerActionProps =
91
90
  selectedIds?: string[]
92
91
  }
93
92
 
94
- /**
95
- * Tracks which (listName, operation) pairs have already warned about an ignored
96
- * `select` argument, so a misused read op warns once rather than on every call.
97
- */
98
93
  const selectWarnings = new Set<string>()
99
94
 
100
95
  /**
101
- * Warn (once per list+operation) when a caller passes a `select` argument to a
102
- * read op that does not honour it.
103
- *
104
- * `context.db` reads never apply Prisma `select` semantics — narrowing is done
105
- * via `include` or a fragment `query`. The op still runs and returns the full,
106
- * access-filtered result, so this is a visible no-op rather than an error.
107
- *
108
- * Centralised here so every affected read op shares one implementation.
96
+ * Warn once per (list, operation) when a caller passes `select` to a read op
97
+ * that ignores it. See "Narrowing Reads" in packages/core/CLAUDE.md.
109
98
  */
110
99
  function warnIfSelectIgnored(
111
100
  args: { select?: unknown } | undefined,
@@ -126,9 +115,6 @@ function warnIfSelectIgnored(
126
115
  )
127
116
  }
128
117
 
129
- /**
130
- * Check if a list is configured as a singleton
131
- */
132
118
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
133
119
  function isSingletonList(listConfig: ListConfig<any>): boolean {
134
120
  return !!listConfig.isSingleton
@@ -136,25 +122,17 @@ function isSingletonList(listConfig: ListConfig<any>): boolean {
136
122
 
137
123
  /**
138
124
  * Compute the set of single-field unique selectors a `findUnique` `where` may be
139
- * keyed by, derived from what the list config exposes at runtime.
125
+ * keyed by, derived from what the list config exposes at runtime: `id`, plus any
126
+ * field declared `isIndexed: 'unique'` — for a `relationship` field, this is the
127
+ * foreign-key column name (`<field>Id`), since that's the column Prisma marks
128
+ * `@unique`, not the relation field itself.
140
129
  *
141
- * The set is:
142
- * - `id` always a unique identifier on every list.
143
- * - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
144
- * - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
145
- * column name (`<field>Id`) that is the column Prisma marks `@unique`, so the
146
- * unique `where` is keyed by `<field>Id`, not the relation field itself.
147
- *
148
- * Chosen rule (documented intentionally): the config does NOT expose compound
149
- * (`@@unique`) keys at runtime — there is no list-level unique declaration in the
150
- * config API — so we cannot validate compound `<Model>_<a>_<b>` selectors. We
151
- * therefore enforce the tractable subset: `where` must contain EXACTLY ONE
152
- * recognised single-field unique key and NO other keys. This rejects non-unique
153
- * filters (the bug in #567) and rejects extra non-unique keys alongside a unique
154
- * one, while never falsely rejecting a valid single-field unique lookup. If a
155
- * project legitimately needs a compound-unique lookup, that path is not covered
156
- * here and would need explicit config support; the safe escape hatch for any
157
- * non-unique single-row lookup is `findFirst` (see #565).
130
+ * The config exposes no list-level compound (`@@unique`) declaration, so this
131
+ * cannot validate a compound `<Model>_<a>_<b>` selector `where` must contain
132
+ * exactly one recognised single-field unique key and no others. This rejects
133
+ * non-unique filters (#567) without ever rejecting a valid single-field unique
134
+ * lookup; a compound-unique or otherwise non-unique lookup should use
135
+ * `findFirst` instead (see #565).
158
136
  */
159
137
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
160
138
  function getUniqueWhereKeys(listConfig: ListConfig<any>): Set<string> {
@@ -200,10 +178,6 @@ function assertUniqueWhere(
200
178
  }
201
179
  }
202
180
 
203
- /**
204
- * Check if auto-create is enabled for a singleton list
205
- * Defaults to true if not explicitly set to false
206
- */
207
181
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
208
182
  function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
209
183
  if (!listConfig.isSingleton) return false
@@ -211,22 +185,15 @@ function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
211
185
  return listConfig.isSingleton.autoCreate !== false
212
186
  }
213
187
 
214
- /**
215
- * Extract default values from field configs
216
- * Used to auto-create singleton records with sensible defaults
217
- */
218
188
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
219
189
  function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
220
190
  const data: Record<string, unknown> = {}
221
191
 
222
192
  for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
223
- // Skip virtual fields - they're not stored in database
224
193
  if (fieldConfig.virtual) continue
225
194
 
226
- // Skip system fields (id, createdAt, updatedAt)
227
195
  if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
228
196
 
229
- // Add default value if present
230
197
  if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
231
198
  data[fieldKey] = fieldConfig.defaultValue
232
199
  }
@@ -235,12 +202,8 @@ function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
235
202
  return data
236
203
  }
237
204
 
238
- /**
239
- * Parse Prisma error and convert to user-friendly DatabaseError
240
- */
241
205
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
242
206
  function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
243
- // Check if it's a Prisma error
244
207
  if (
245
208
  error &&
246
209
  typeof error === 'object' &&
@@ -250,15 +213,13 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
250
213
  ) {
251
214
  const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
252
215
 
253
- // Handle unique constraint violation
216
+ // P2002 is Prisma's unique constraint violation code.
254
217
  if (prismaError.code === 'P2002') {
255
218
  const target = prismaError.meta?.target
256
219
  const fieldErrors: Record<string, string> = {}
257
220
 
258
221
  if (target && Array.isArray(target)) {
259
- // Get field names from the constraint target
260
222
  for (const fieldName of target) {
261
- // Get the field config to get a better label
262
223
  const fieldConfig = listConfig.fields[fieldName]
263
224
  const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
264
225
 
@@ -269,7 +230,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
269
230
  }
270
231
  }
271
232
 
272
- // Create a user-friendly general message
273
233
  const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
274
234
  return new DatabaseError(
275
235
  `${fieldLabels} must be unique. The value you entered is already in use.`,
@@ -281,7 +241,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
281
241
  return new DatabaseError('A record with this value already exists', {}, prismaError.code)
282
242
  }
283
243
 
284
- // Handle other Prisma errors - return generic message
285
244
  return new DatabaseError(
286
245
  prismaError.message || 'A database error occurred',
287
246
  {},
@@ -289,22 +248,16 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
289
248
  )
290
249
  }
291
250
 
292
- // Not a Prisma error, return as-is if it's already an Error
293
251
  if (error instanceof Error) {
294
252
  return error
295
253
  }
296
254
 
297
- // Unknown error type
298
255
  return new Error('An unknown error occurred')
299
256
  }
300
257
 
301
258
  /**
302
- * Database transaction isolation levels.
303
- *
304
- * Mirrors Prisma's `TransactionIsolationLevel`. The level passed to
305
- * {@link StackContext.transaction} is forwarded to the underlying interactive
306
- * transaction; provider support varies (e.g. `Serializable` is supported by
307
- * PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
259
+ * Mirrors Prisma's `TransactionIsolationLevel`. Provider support varies —
260
+ * e.g. `Serializable` requires PostgreSQL.
308
261
  */
309
262
  export type TransactionIsolationLevel =
310
263
  'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable' | 'Snapshot'
@@ -380,13 +333,34 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
380
333
  }
381
334
 
382
335
  /**
383
- * Create an access-controlled context
384
- *
385
- * @param config - OpenSaas configuration
386
- * @param prisma - Your Prisma client instance (pass as generic for type safety)
387
- * @param session - Current session object (or null if not authenticated)
388
- * @param storage - Optional storage utilities (uploadFile, uploadImage, deleteFile, deleteImage)
336
+ * Drain a `context.transaction()` owner's deferral registry once its callback
337
+ * (and any real underlying transaction) has settled (ADR-0028). A transaction/
338
+ * callback error always wins — compensators still all run, but their errors
339
+ * are discarded in favor of re-surfacing the original, matching the Write
340
+ * Pipeline's `txError` precedence otherwise any deferred `afterTransaction`
341
+ * errors reject with {@link AfterTransactionError} even though the callback
342
+ * succeeded and the transaction committed.
389
343
  */
344
+ async function settleTransactionOwner<T>(
345
+ settled: Promise<T>,
346
+ registry: TransactionRegistry,
347
+ ): Promise<T> {
348
+ const errors: unknown[] = []
349
+ let result: T
350
+ try {
351
+ result = await settled
352
+ } catch (err) {
353
+ const outcome: TransactionSettleOutcome = { status: 'rolled-back', error: err }
354
+ await registry.drain(outcome, errors)
355
+ throw err
356
+ }
357
+ await registry.drain({ status: 'committed' }, errors)
358
+ if (errors.length > 0) {
359
+ throw new AfterTransactionError(errors)
360
+ }
361
+ return result
362
+ }
363
+
390
364
  export function getContext<
391
365
  TConfig extends OpenSaasConfig,
392
366
  TPrisma extends PrismaClientLike = PrismaClientLike,
@@ -399,13 +373,14 @@ export function getContext<
399
373
  // Internal: when rebuilding the context against a transaction client, reuse the
400
374
  // already-initialised plugin services rather than re-running plugin runtimes.
401
375
  _sharedPlugins?: Record<string, unknown>,
376
+ // Internal (ADR-0028, #899): when rebuilding the context for a transaction
377
+ // owner's callback body, carry the deferral registry so writes reached
378
+ // through this context join it instead of firing afterTransaction eagerly.
379
+ _transactionOwner?: TransactionRegistry,
402
380
  ): StackContext<TPrisma> {
403
- // Initialize db object - will be populated with access-controlled operations
404
- // Type is intentionally broad to allow dynamic model access
381
+ // Broad type to allow dynamic model access; populated by populateDbDelegate below.
405
382
  const db: Record<string, unknown> = {}
406
383
 
407
- // Create context with db reference (will be populated below)
408
- // Storage utilities can be provided via parameter or use default stubs
409
384
  const context: AccessContext<TPrisma> = {
410
385
  session,
411
386
  prisma: prisma as TPrisma,
@@ -437,15 +412,13 @@ export function getContext<
437
412
  plugins: _sharedPlugins ?? {},
438
413
  _isSudo,
439
414
  _resolveOutputChain: [],
415
+ _transactionOwner,
440
416
  }
441
417
 
442
- // Create access-controlled operations for each list, populating `db` in place.
443
418
  populateDbDelegate(db, config, prisma, context)
444
419
 
445
- // Execute plugin runtime functions and populate context.plugins.
446
420
  // Skipped when reusing shared plugins (transaction rebind) so runtimes — and
447
421
  // any side effects they carry — run exactly once per top-level context.
448
- // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
449
422
  if (!_sharedPlugins) {
450
423
  const pluginsToExecute = config._plugins || config.plugins || []
451
424
  for (const plugin of pluginsToExecute) {
@@ -459,40 +432,24 @@ export function getContext<
459
432
  )
460
433
  } catch (error) {
461
434
  console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
462
- // Continue with other plugins even if one fails
463
435
  }
464
436
  }
465
437
  }
466
438
  }
467
439
 
468
- // Generic server action handler with discriminated union for type safety
469
- // Returns a result object instead of throwing to work properly in Next.js production
440
+ // Returns a result object instead of throwing — required for server actions
441
+ // to work in Next.js production builds.
470
442
  async function serverAction(props: ServerActionProps): Promise<
471
443
  | { success: true; data: unknown }
472
444
  | { success: false; error: string; fieldErrors?: Record<string, string> }
473
- // Bulk actions report a count rather than a single-op `success` flag: the
474
- // shape is deliberately distinct so a UI wrapper that redirects on a
475
- // single-item `success` (the item-form pattern) does not hijack a
476
- // list-level bulk operation.
445
+ // The distinct shapes below (never `success`) mirror the ServerActionProps
446
+ // variants above see their comments for the redirect-on-`success`
447
+ // footgun each one avoids.
477
448
  | { deleted: number; total: number }
478
- // Relationship-table row removal reports `removed` (with an optional reason)
479
- // rather than `success` — same distinct-shape rationale as `bulkDelete`, so
480
- // an in-place removal never triggers a redirect-on-success wrapper.
481
449
  | { removed: boolean; error?: string }
482
- // Relationship-table pre-linked create reports `created` (with the new row's
483
- // id, or an error + fieldErrors for the drawer) rather than `success` — same
484
- // distinct-shape rationale, so an in-place create never triggers a
485
- // redirect-on-success wrapper.
486
450
  | { created: boolean; id?: string; error?: string; fieldErrors?: Record<string, string> }
487
- // Custom Bulk action (issue #736) reports `bulkAction` with the handler's
488
- // optional `message` (success) or an `error` (not found / denied / threw) —
489
- // again a distinct shape from single-op `success`.
490
451
  | { bulkAction: true; message?: string }
491
452
  | { bulkAction: false; error: string }
492
- // Relationship-table inline cell edit reports `updated` (with an optional
493
- // reason + fieldErrors for the edited cell) rather than `success` — same
494
- // distinct-shape rationale, so an in-place cell edit never triggers a
495
- // redirect-on-success wrapper.
496
453
  | { updated: boolean; error?: string; fieldErrors?: Record<string, string> }
497
454
  > {
498
455
  const dbKey = getDbKey(props.listKey)
@@ -579,8 +536,7 @@ export function getContext<
579
536
  }
580
537
  }
581
538
 
582
- // Relationship-table row removal (ADR-0018, #739). Runs on the RELATED row
583
- // through the secured context, so the related list's access + hooks apply.
539
+ // Runs on the RELATED row (ADR-0018 boundary see ServerActionProps above).
584
540
  // Honours Silent failure: an access-denied operation returns `null`, which
585
541
  // becomes `{ removed: false }` with a generic reason — never leaking whether
586
542
  // the row was denied or absent.
@@ -621,14 +577,13 @@ export function getContext<
621
577
  }
622
578
  }
623
579
 
624
- // Relationship-table pre-linked create (ADR-0018, #738). Creates a row on
625
- // the RELATED list through the secured context, so the related list's create
626
- // access + hooks (and field-level access) apply never the parent's. The
627
- // back-reference to the parent is set here from `field`/`parentId` (a to-one
628
- // back-ref connects a single parent; a to-many back-ref, e.g. many-to-many,
629
- // connects the parent by id), so the client can never re-target the link.
630
- // Honours Silent failure: an access-denied create returns `null`, surfaced
631
- // as `{ created: false }` with a generic reason (no denied-vs-absent leak).
580
+ // Runs on the RELATED list (ADR-0018 boundary see ServerActionProps above).
581
+ // The back-reference to the parent is set here from `field`/`parentId` (a
582
+ // to-one back-ref connects a single parent; a to-many back-ref, e.g.
583
+ // many-to-many, connects the parent by id), so the client can never
584
+ // re-target the link. Honours Silent failure: an access-denied create
585
+ // returns `null`, surfaced as `{ created: false }` with a generic reason
586
+ // (no denied-vs-absent leak).
632
587
  if (props.action === 'createRelated') {
633
588
  try {
634
589
  // Defensive guard (hardening; unreachable from the drawer, which always
@@ -686,11 +641,9 @@ export function getContext<
686
641
  }
687
642
  }
688
643
 
689
- // Relationship-table inline cell edit (ADR-0018, #737). Updates ONE scalar
690
- // field on the RELATED row through the secured context, so the related list's
691
- // operation- and field-level update access plus its hooks/validation apply
692
- // never the parent's. Honours Silent failure: an access-denied update returns
693
- // `null`, surfaced as `{ updated: false }` with a generic reason (no
644
+ // Updates ONE scalar field on the RELATED row (ADR-0018 boundary see
645
+ // ServerActionProps above). Honours Silent failure: an access-denied update
646
+ // returns `null`, surfaced as `{ updated: false }` with a generic reason (no
694
647
  // denied-vs-absent leak); a validation/db error surfaces its message and
695
648
  // fieldErrors so the cell can revert with a reason and show an inline error.
696
649
  if (props.action === 'updateRelated') {
@@ -765,7 +718,6 @@ export function getContext<
765
718
  data: result,
766
719
  }
767
720
  } catch (error) {
768
- // Handle ValidationError (has fieldErrors)
769
721
  if (error instanceof ValidationError) {
770
722
  return {
771
723
  success: false,
@@ -774,7 +726,6 @@ export function getContext<
774
726
  }
775
727
  }
776
728
 
777
- // Handle DatabaseError (has fieldErrors)
778
729
  if (error instanceof DatabaseError) {
779
730
  return {
780
731
  success: false,
@@ -783,7 +734,6 @@ export function getContext<
783
734
  }
784
735
  }
785
736
 
786
- // Parse and convert Prisma errors to user-friendly DatabaseError
787
737
  const dbError = parsePrismaError(error, listConfig)
788
738
  if (dbError instanceof DatabaseError) {
789
739
  return {
@@ -793,7 +743,6 @@ export function getContext<
793
743
  }
794
744
  }
795
745
 
796
- // Generic error fallback
797
746
  return {
798
747
  success: false,
799
748
  error: dbError.message,
@@ -801,34 +750,77 @@ export function getContext<
801
750
  }
802
751
  }
803
752
 
804
- // Sudo function - creates a new context that bypasses access control
805
- // but still executes all hooks and validation
753
+ // Bypasses access control; hooks and validation still run.
806
754
  function sudo(): StackContext<TPrisma> {
807
- return getContext(config, prisma, session, context.storage, true)
755
+ return getContext(
756
+ config,
757
+ prisma,
758
+ session,
759
+ context.storage,
760
+ true,
761
+ undefined,
762
+ // ADR-0028: a sudo write issued from inside an owned transaction (e.g.
763
+ // `tx.sudo().db.x.create()`) must still defer to that owner.
764
+ context._transactionOwner,
765
+ )
808
766
  }
809
767
 
810
- // Interactive, hook-firing transaction (#614). Rebinds the access-controlled
811
- // context to the transaction client so every `txContext.db.*` write runs its
812
- // access checks + hooks but persists inside ONE transaction (atomic). The
813
- // transaction `options` (e.g. `isolationLevel`) pass through to Prisma, and a
814
- // serialization failure thrown inside the callback propagates to the caller
815
- // for retry (it is never converted to a silent `null`).
768
+ // Interactive, hook-firing transaction (#614). See the `transaction` doc on
769
+ // `StackContext` above for the atomicity/isolation/retry contract.
770
+ //
771
+ // This call OWNS a deferral registry for its callback's writes (ADR-0028):
772
+ // it always observes when its own callback settles resolve/reject — even
773
+ // when the underlying client cannot open a real interactive transaction, so
774
+ // every `txContext.db.*` write defers its transaction-boundary bracket here
775
+ // instead of firing eagerly, and this call flushes them with the real
776
+ // outcome once the callback (and any real transaction) has settled. A
777
+ // `transaction()` nested inside another joins the outer owner's queue
778
+ // rather than creating a second one.
816
779
  function transaction<T>(
817
780
  fn: (txContext: StackContext<TPrisma>) => Promise<T>,
818
781
  options?: TransactionOptions,
819
782
  ): Promise<T> {
820
- const client = prisma as unknown as TransactionCapable<TPrisma>
821
- if (typeof client.$transaction !== 'function') {
822
- // No interactive transaction available — either a plain client/mock or we
823
- // are already inside a transaction (a Prisma tx client exposes no
824
- // `$transaction`). Run directly: hook/access semantics are identical and
825
- // atomicity is provided by any enclosing transaction.
783
+ if (context._transactionOwner) {
826
784
  return fn(returned)
827
785
  }
828
- return client.$transaction(
829
- (tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins)),
830
- options,
831
- ) as Promise<T>
786
+
787
+ const registry = new TransactionRegistry()
788
+ const client = prisma as unknown as TransactionCapable<TPrisma>
789
+
790
+ const settled =
791
+ typeof client.$transaction !== 'function'
792
+ ? // No interactive transaction available (plain client/mock, or already
793
+ // inside one — see `TransactionCapable` above). Run directly: hook/
794
+ // access semantics are identical, atomicity comes from the enclosing
795
+ // transaction.
796
+ fn(
797
+ getContext(
798
+ config,
799
+ prisma,
800
+ session,
801
+ context.storage,
802
+ _isSudo,
803
+ context.plugins,
804
+ registry,
805
+ ),
806
+ )
807
+ : (client.$transaction(
808
+ (tx) =>
809
+ fn(
810
+ getContext(
811
+ config,
812
+ tx,
813
+ session,
814
+ context.storage,
815
+ _isSudo,
816
+ context.plugins,
817
+ registry,
818
+ ),
819
+ ),
820
+ options,
821
+ ) as Promise<T>)
822
+
823
+ return settleTransactionOwner(settled, registry)
832
824
  }
833
825
 
834
826
  const returned: StackContext<TPrisma> = {
@@ -865,7 +857,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
865
857
  for (const [listName, listConfig] of Object.entries(config.lists)) {
866
858
  const dbKey = getDbKey(listName)
867
859
 
868
- // Create base operations
869
860
  const createOp = createCreate(listName, listConfig, prisma, context, config)
870
861
  const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
871
862
  const updateOp = createUpdate(listName, listConfig, prisma, context, config)
@@ -876,7 +867,7 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
876
867
  create: createOp,
877
868
  update: updateOp,
878
869
  delete: createDelete(listName, listConfig, prisma, context, config),
879
- count: createCount(listName, listConfig, prisma, context),
870
+ count: createCount(listName, listConfig, prisma, context, config),
880
871
  createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
881
872
  updateMany: createUpdateMany(
882
873
  listName,
@@ -889,7 +880,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
889
880
  ),
890
881
  }
891
882
 
892
- // Add get() method for singleton lists
893
883
  if (isSingletonList(listConfig)) {
894
884
  operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
895
885
  }
@@ -921,11 +911,19 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
921
911
  *
922
912
  * A fragment's own `include` and a sudo caller's `include` are folded and
923
913
  * used as-is, matching their existing (unmerged) treatment. A non-sudo
924
- * caller include is folded and then merged through the same
925
- * access-scoping pipeline as before. A bare read stays on the exact
926
- * ADR-0024 path `include: undefined`, no related `query` access
927
- * evaluated unless folding actually added something, which only happens
928
- * when a field on this list declares `needs`.
914
+ * caller include is folded and then scoped by `buildAccessScopedInclude`
915
+ * (ADR-0026) caller-directed, so a relation named nowhere in the folded
916
+ * tree never has its list's `query` access evaluated at all. A bare read
917
+ * stays on the exact ADR-0024 path `include: undefined`, no related
918
+ * `query` access evaluated unless folding actually added something, which
919
+ * only happens when a field on this list declares `needs`.
920
+ *
921
+ * Also returns the `FieldSelectionScope` a fragment's own field selection
922
+ * produces (ADR-0027), so the caller can pass it to `filterReadableFields`
923
+ * and make computation itself projection-aware, not only the fold above.
924
+ * `undefined` for every non-fragment path: a caller `include` (sudo or not)
925
+ * and a bare read both mean "compute every field," matching what they
926
+ * already fetch.
929
927
  */
930
928
  async function resolveReadInclude(
931
929
  callerInclude: Record<string, unknown> | undefined,
@@ -935,43 +933,45 @@ async function resolveReadInclude(
935
933
  listConfig: ListConfig<any>,
936
934
  context: AccessContext & { _isSudo?: boolean },
937
935
  config: OpenSaasConfig,
938
- ): Promise<{ include: Record<string, unknown> | undefined; declaredOnly: DeclaredOnlyTree }> {
936
+ ): Promise<{
937
+ include: Record<string, unknown> | undefined
938
+ declaredOnly: DeclaredOnlyTree
939
+ selection: FieldSelectionScope | undefined
940
+ }> {
939
941
  if (fragmentFields !== undefined) {
940
942
  const fragmentInclude = buildInclude(fragmentFields) ?? undefined
941
- return foldDeclaredDependencies(fragmentInclude, listConfig.fields, config)
943
+ const selection = buildFieldSelectionScope(fragmentFields)
944
+ const folded = foldDeclaredDependencies(
945
+ fragmentInclude,
946
+ listConfig.fields,
947
+ config,
948
+ listName,
949
+ [listName],
950
+ selection,
951
+ )
952
+ return { ...folded, selection }
942
953
  }
943
954
 
944
955
  if (context._isSudo) {
945
- return foldDeclaredDependencies(callerInclude, listConfig.fields, config)
956
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
957
+ return { ...folded, selection: undefined }
946
958
  }
947
959
 
948
- const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config)
960
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
949
961
  if (!folded.include) {
950
- return folded
962
+ return { ...folded, selection: undefined }
951
963
  }
952
964
 
953
- const accessControlledInclude = await buildIncludeWithAccessControl(
954
- listConfig.fields,
955
- { session: context.session, context },
956
- config,
957
- 0,
958
- // Seed the cycle guard with the root list so a relationship cycle back
959
- // to it (self-referential or longer) stops re-descending.
960
- [listName],
961
- )
962
- const include = mergeIncludeWithAccessControl(
965
+ const include = await buildAccessScopedInclude(
963
966
  folded.include,
964
- accessControlledInclude,
965
967
  listConfig.fields,
968
+ { session: context.session, context },
966
969
  config,
967
970
  listName,
968
971
  )
969
- return { include, declaredOnly: folded.declaredOnly }
972
+ return { include, declaredOnly: folded.declaredOnly, selection: undefined }
970
973
  }
971
974
 
972
- /**
973
- * Create findUnique operation with access control
974
- */
975
975
  function createFindUnique<TPrisma extends PrismaClientLike>(
976
976
  listName: string,
977
977
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -991,19 +991,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
991
991
  // `select` is not honoured — accepted only so the no-op can be made visible.
992
992
  select?: Record<string, unknown>
993
993
  }) => {
994
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
995
994
  warnIfSelectIgnored(args, listName, 'findUnique')
996
995
 
997
- // Enforce unique-`where` (Keystone `findOne` parity). This is a caller-shape
998
- // check independent of access, so it runs first and THROWS on misuse — it is
999
- // not an access denial and must not be masked as a silent `null`. The
1000
- // type-level constraint already lives on the generated delegate: the custom
1001
- // `<List>FindUniqueArgs` only Omits `select`/`include` from
1002
- // `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
1003
- // `<List>WhereUniqueInput` — this runtime guard backstops untyped callers.
996
+ // Runs first, before the access check below a non-unique `where` is a
997
+ // caller-shape error (see `assertUniqueWhere`), not an access denial. The
998
+ // generated `<List>FindUniqueArgs` only Omits `select`/`include` from
999
+ // Prisma's own type, so `where` stays `<List>WhereUniqueInput` this
1000
+ // runtime guard backstops untyped callers.
1004
1001
  assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName)
1005
1002
 
1006
- // Check query access (skip if sudo mode)
1007
1003
  let where: Record<string, unknown> = args.where
1008
1004
  if (!context._isSudo) {
1009
1005
  const queryAccess = listConfig.access?.operation?.query
@@ -1016,7 +1012,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1016
1012
  return null
1017
1013
  }
1018
1014
 
1019
- // Merge access filter with where clause
1020
1015
  const mergedWhere = mergeFilters(args.where, accessResult)
1021
1016
  if (mergedWhere === null) {
1022
1017
  return null
@@ -1024,15 +1019,14 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1024
1019
  where = mergedWhere
1025
1020
  }
1026
1021
 
1027
- // When a query fragment is provided, build the include from the fragment
1028
- // instead of the access-controlled include. Access control still runs via
1029
- // filterReadableFields; the fragment then narrows to only the requested fields.
1022
+ // Access control still runs via filterReadableFields even though a
1023
+ // fragment drives `include`; the fragment only narrows which fields come back.
1030
1024
  const fragment = isFragment(args.query) ? args.query : null
1031
1025
 
1032
1026
  // Resolve `include`, folding any declared dependencies (`needs`,
1033
1027
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1034
1028
  // already produces — see `resolveReadInclude`'s doc comment.
1035
- let { include, declaredOnly } = await resolveReadInclude(
1029
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1036
1030
  args.include,
1037
1031
  fragment ? fragment._fields : undefined,
1038
1032
  listName,
@@ -1044,11 +1038,12 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1044
1038
  // Virtual fields have no database column. Whichever path produced
1045
1039
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
1046
1040
  // virtual key must never reach Prisma — it would throw "Unknown field"
1047
- // (#628). The virtual value is still computed unconditionally below by
1048
- // `filterReadableFields`, independent of what was requested here.
1041
+ // (#628). Below, `filterReadableFields` computes a virtual field's value
1042
+ // exactly when `selection` says the read is going to return it (ADR-0027)
1043
+ // — every one of them for a bare/`include`-based read (`selection` is
1044
+ // `undefined`), only the ones a fragment named otherwise.
1049
1045
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1050
1046
 
1051
- // Execute query with optimized includes
1052
1047
  // Access Prisma model dynamically - required because model names are generated at runtime
1053
1048
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1054
1049
  const model = (prisma as any)[getDbKey(listName)]
@@ -1061,7 +1056,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1061
1056
  return null
1062
1057
  }
1063
1058
 
1064
- // Filter readable fields and apply resolveOutput hooks (including nested relationships)
1065
1059
  // Pass sudo flag through context to skip field-level access checks
1066
1060
  const filtered = await filterReadableFields(
1067
1061
  item,
@@ -1074,9 +1068,9 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1074
1068
  0,
1075
1069
  listName,
1076
1070
  declaredOnly,
1071
+ selection,
1077
1072
  )
1078
1073
 
1079
- // When a fragment is provided, pick only the requested fields from the result
1080
1074
  if (fragment) {
1081
1075
  return pickFields(filtered, fragment._fields)
1082
1076
  }
@@ -1085,9 +1079,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1085
1079
  }
1086
1080
  }
1087
1081
 
1088
- /**
1089
- * Create findMany operation with access control
1090
- */
1091
1082
  function createFindMany<TPrisma extends PrismaClientLike>(
1092
1083
  listName: string,
1093
1084
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1107,10 +1098,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1107
1098
  // `select` is not honoured — accepted only so the no-op can be made visible.
1108
1099
  select?: Record<string, unknown>
1109
1100
  }) => {
1110
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
1111
1101
  warnIfSelectIgnored(args, listName, 'findMany')
1112
1102
 
1113
- // Check singleton constraint (throw error instead of silently returning empty)
1103
+ // Singleton misuse throws rather than silently returning `[]` — unlike an
1104
+ // access denial, this is a caller-shape error.
1114
1105
  if (isSingletonList(listConfig)) {
1115
1106
  throw new ValidationError(
1116
1107
  [`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`],
@@ -1118,7 +1109,8 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1118
1109
  )
1119
1110
  }
1120
1111
 
1121
- // Check query access (skip if sudo mode)
1112
+ // Check query access first (skip if sudo mode) — this MUST run before the
1113
+ // #912/#915 where/orderBy validation below. See the comment there for why.
1122
1114
  let where: Record<string, unknown> | undefined = args?.where
1123
1115
  if (!context._isSudo) {
1124
1116
  const queryAccess = listConfig.access?.operation?.query
@@ -1131,21 +1123,62 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1131
1123
  return []
1132
1124
  }
1133
1125
 
1134
- // Merge access filter with where clause
1135
- const mergedWhere = mergeFilters(args?.where, accessResult)
1126
+ // #912 reject a `where`/`orderBy` key the list config doesn't declare
1127
+ // (e.g. a Prisma-generated back-relation), and #915 — reject one naming
1128
+ // a field this session cannot READ (closing a probe via a `count()`
1129
+ // that varies with the withheld value, or an `orderBy` that leaks
1130
+ // relative ordering). Both run only now that the caller is known to
1131
+ // have SOME access to the list (`accessResult !== false`): the thrown
1132
+ // errors name the offending key, and running them before the access
1133
+ // check above would let a caller with ZERO access to the list learn a
1134
+ // field's name and read-gating status from the error message alone —
1135
+ // turning the validation itself into the kind of oracle #915 closes.
1136
+ // `sudo` bypasses this whole branch, matching the write path.
1137
+ validateQueryKeys({
1138
+ where: args?.where,
1139
+ orderBy: args?.orderBy,
1140
+ listConfig,
1141
+ listName,
1142
+ config,
1143
+ isSudo: false,
1144
+ })
1145
+ await validateQueryFieldReadAccess({
1146
+ where: args?.where,
1147
+ orderBy: args?.orderBy,
1148
+ listConfig,
1149
+ listName,
1150
+ session: context.session,
1151
+ context,
1152
+ isSudo: false,
1153
+ })
1154
+
1155
+ // #916 — scope every relation filter nested in `where`
1156
+ // (`some`/`every`/`none`/`is`/`isNot`) by the RELATED list's own `query`
1157
+ // access, recursing through every hop of a chain — the `where`
1158
+ // counterpart to how `include` is already scoped below via
1159
+ // `buildAccessScopedInclude`. Runs after the checks above for the same
1160
+ // ordering reason: only once the caller is known to have SOME access to
1161
+ // THIS list.
1162
+ const scopedWhere = args?.where
1163
+ ? ((await buildAccessScopedWhere(args.where, listConfig, listName, config, {
1164
+ session: context.session,
1165
+ context,
1166
+ })) as Record<string, unknown>)
1167
+ : args?.where
1168
+
1169
+ const mergedWhere = mergeFilters(scopedWhere, accessResult)
1136
1170
  if (mergedWhere === null) {
1137
1171
  return []
1138
1172
  }
1139
1173
  where = mergedWhere
1140
1174
  }
1141
1175
 
1142
- // When a query fragment is provided, build include from fragment fields
1143
1176
  const fragment = isFragment(args?.query) ? args.query : null
1144
1177
 
1145
1178
  // Resolve `include`, folding any declared dependencies (`needs`,
1146
1179
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1147
1180
  // already produces — see `resolveReadInclude`'s doc comment.
1148
- let { include, declaredOnly } = await resolveReadInclude(
1181
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1149
1182
  args?.include,
1150
1183
  fragment ? fragment._fields : undefined,
1151
1184
  listName,
@@ -1154,14 +1187,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1154
1187
  config,
1155
1188
  )
1156
1189
 
1157
- // Virtual fields have no database column. Whichever path produced
1158
- // `include` (fragment, access-controlled merge, or sudo passthrough), a
1159
- // virtual key must never reach Prisma — it would throw "Unknown field"
1160
- // (#628). The virtual value is still computed unconditionally below by
1161
- // `filterReadableFields`, independent of what was requested here.
1190
+ // Strips virtual keys from `include` before the Prisma call — see the
1191
+ // `createFindUnique` comment above for why (#628, ADR-0027).
1162
1192
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1163
1193
 
1164
- // Execute query with optimized includes
1165
1194
  // Access Prisma model dynamically - required because model names are generated at runtime
1166
1195
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1167
1196
  const model = (prisma as any)[getDbKey(listName)]
@@ -1173,7 +1202,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1173
1202
  include,
1174
1203
  })
1175
1204
 
1176
- // Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
1177
1205
  // Pass sudo flag through context to skip field-level access checks
1178
1206
  const filtered = await Promise.all(
1179
1207
  items.map((item: Record<string, unknown>) =>
@@ -1188,11 +1216,11 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1188
1216
  0,
1189
1217
  listName,
1190
1218
  declaredOnly,
1219
+ selection,
1191
1220
  ),
1192
1221
  ),
1193
1222
  )
1194
1223
 
1195
- // When a fragment is provided, pick only the requested fields from each result
1196
1224
  if (fragment) {
1197
1225
  return filtered.map((item: Record<string, unknown>) => pickFields(item, fragment._fields))
1198
1226
  }
@@ -1226,9 +1254,6 @@ function createFindFirst(findManyOp: ReturnType<typeof createFindMany>) {
1226
1254
  }
1227
1255
  }
1228
1256
 
1229
- /**
1230
- * Create create operation with access control and hooks
1231
- */
1232
1257
  function createCreate<TPrisma extends PrismaClientLike>(
1233
1258
  listName: string,
1234
1259
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1252,10 +1277,8 @@ function createCreate<TPrisma extends PrismaClientLike>(
1252
1277
  }
1253
1278
  }
1254
1279
 
1255
- /**
1256
- * Create createMany operation with access control and hooks
1257
- * Runs create in a loop to ensure all hooks and access control are executed for each item
1258
- */
1280
+ // Runs create in a loop (not Prisma's native createMany) so every item still
1281
+ // gets its own hooks and access control.
1259
1282
  function createCreateMany<TPrisma extends PrismaClientLike>(
1260
1283
  listName: string,
1261
1284
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1278,9 +1301,6 @@ function createCreateMany<TPrisma extends PrismaClientLike>(
1278
1301
  }
1279
1302
  }
1280
1303
 
1281
- /**
1282
- * Create update operation with access control and hooks
1283
- */
1284
1304
  function createUpdate<TPrisma extends PrismaClientLike>(
1285
1305
  listName: string,
1286
1306
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1304,10 +1324,8 @@ function createUpdate<TPrisma extends PrismaClientLike>(
1304
1324
  }
1305
1325
  }
1306
1326
 
1307
- /**
1308
- * Create updateMany operation with access control and hooks
1309
- * Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
1310
- */
1327
+ // Finds matching records, then updates each individually (not Prisma's native
1328
+ // updateMany) so every item still gets its own hooks and access control.
1311
1329
  function createUpdateMany<TPrisma extends PrismaClientLike>(
1312
1330
  listName: string,
1313
1331
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1321,10 +1339,8 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
1321
1339
  updateFn: any,
1322
1340
  ) {
1323
1341
  return async (args: { where?: Record<string, unknown>; data: Record<string, unknown> }) => {
1324
- // First, find all matching records (respects access control)
1325
1342
  const items = await findManyFn({ where: args.where })
1326
1343
 
1327
- // Then update each one individually (runs hooks and access control for each)
1328
1344
  const results = []
1329
1345
  for (const item of items) {
1330
1346
  const result = await updateFn({ where: { id: item.id }, data: args.data })
@@ -1335,9 +1351,6 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
1335
1351
  }
1336
1352
  }
1337
1353
 
1338
- /**
1339
- * Create delete operation with access control and hooks
1340
- */
1341
1354
  function createDelete<TPrisma extends PrismaClientLike>(
1342
1355
  listName: string,
1343
1356
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1361,18 +1374,17 @@ function createDelete<TPrisma extends PrismaClientLike>(
1361
1374
  }
1362
1375
  }
1363
1376
 
1364
- /**
1365
- * Create count operation with access control
1366
- */
1367
1377
  function createCount<TPrisma extends PrismaClientLike>(
1368
1378
  listName: string,
1369
1379
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
1370
1380
  listConfig: ListConfig<any>,
1371
1381
  prisma: TPrisma,
1372
1382
  context: AccessContext<TPrisma>,
1383
+ config: OpenSaasConfig,
1373
1384
  ) {
1374
1385
  return async (args?: { where?: Record<string, unknown> }) => {
1375
- // Check query access (skip if sudo mode)
1386
+ // Check query access first (skip if sudo mode) — this MUST run before the
1387
+ // #912/#915 where validation below. See the comment there for why.
1376
1388
  let where: Record<string, unknown> | undefined = args?.where
1377
1389
  if (!context._isSudo) {
1378
1390
  const queryAccess = listConfig.access?.operation?.query
@@ -1385,15 +1397,50 @@ function createCount<TPrisma extends PrismaClientLike>(
1385
1397
  return 0
1386
1398
  }
1387
1399
 
1388
- // Merge access filter with where clause
1389
- const mergedWhere = mergeFilters(args?.where, accessResult)
1400
+ // #912 reject a `where` key the list config doesn't declare (e.g. a
1401
+ // Prisma-generated back-relation), and #915 — reject one naming a field
1402
+ // this session cannot READ. `count` leaks the most cleanly of any read
1403
+ // op — a bare count answers a predicate with no rows returned at all —
1404
+ // so it gets the same reject, not a lesser one. Both run only now that
1405
+ // the caller is known to have SOME access to the list (`accessResult
1406
+ // !== false`) — see the identical comment in `createFindMany` for why
1407
+ // that ordering matters: running them before the access check would
1408
+ // let a fully-denied caller learn a field's name and read-gating
1409
+ // status from the thrown error alone. `sudo` bypasses this whole
1410
+ // branch, matching the write path.
1411
+ validateQueryKeys({
1412
+ where: args?.where,
1413
+ listConfig,
1414
+ listName,
1415
+ config,
1416
+ isSudo: false,
1417
+ })
1418
+ await validateQueryFieldReadAccess({
1419
+ where: args?.where,
1420
+ listConfig,
1421
+ listName,
1422
+ session: context.session,
1423
+ context,
1424
+ isSudo: false,
1425
+ })
1426
+
1427
+ // #916 — scope every relation filter nested in `where` by the RELATED
1428
+ // list's own `query` access. See the identical comment in
1429
+ // `createFindMany` for why this runs here, in this order.
1430
+ const scopedWhere = args?.where
1431
+ ? ((await buildAccessScopedWhere(args.where, listConfig, listName, config, {
1432
+ session: context.session,
1433
+ context,
1434
+ })) as Record<string, unknown>)
1435
+ : args?.where
1436
+
1437
+ const mergedWhere = mergeFilters(scopedWhere, accessResult)
1390
1438
  if (mergedWhere === null) {
1391
1439
  return 0
1392
1440
  }
1393
1441
  where = mergedWhere
1394
1442
  }
1395
1443
 
1396
- // Execute count
1397
1444
  // Access Prisma model dynamically - required because model names are generated at runtime
1398
1445
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1399
1446
  const model = (prisma as any)[getDbKey(listName)]
@@ -1405,10 +1452,6 @@ function createCount<TPrisma extends PrismaClientLike>(
1405
1452
  }
1406
1453
  }
1407
1454
 
1408
- /**
1409
- * Create get operation for singleton lists
1410
- * Returns the single record, or auto-creates it if enabled
1411
- */
1412
1455
  function createGet<TPrisma extends PrismaClientLike>(
1413
1456
  listName: string,
1414
1457
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1426,15 +1469,12 @@ function createGet<TPrisma extends PrismaClientLike>(
1426
1469
  // `select` is not honoured — accepted only so the no-op can be made visible.
1427
1470
  select?: Record<string, unknown>
1428
1471
  }) => {
1429
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
1430
1472
  warnIfSelectIgnored(args, listName, 'get')
1431
1473
 
1432
- // First try to find the existing record
1433
1474
  // Access Prisma model dynamically - required because model names are generated at runtime
1434
1475
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1435
1476
  const model = (prisma as any)[getDbKey(listName)]
1436
1477
 
1437
- // Check query access (skip if sudo mode)
1438
1478
  let where: Record<string, unknown> = {}
1439
1479
  if (!context._isSudo) {
1440
1480
  const queryAccess = listConfig.access?.operation?.query
@@ -1447,21 +1487,21 @@ function createGet<TPrisma extends PrismaClientLike>(
1447
1487
  return null
1448
1488
  }
1449
1489
 
1450
- // Merge access filter (for singleton, we don't have a specific where clause)
1490
+ // A singleton has no per-record `where`, so the access filter (if any) is
1491
+ // the whole `where`.
1451
1492
  if (accessResult && typeof accessResult === 'object') {
1452
1493
  where = accessResult
1453
1494
  }
1454
1495
  }
1455
1496
 
1456
- // When a query fragment is provided, build the include from the fragment
1457
- // instead of the access-controlled include. Access control still runs via
1458
- // filterReadableFields; the fragment then narrows to only the requested fields.
1497
+ // Access control still runs via filterReadableFields even though a
1498
+ // fragment drives `include`; the fragment only narrows which fields come back.
1459
1499
  const fragment = isFragment(args?.query) ? args.query : null
1460
1500
 
1461
1501
  // Resolve `include`, folding any declared dependencies (`needs`,
1462
1502
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1463
1503
  // already produces — see `resolveReadInclude`'s doc comment.
1464
- let { include, declaredOnly } = await resolveReadInclude(
1504
+ let { include, declaredOnly, selection } = await resolveReadInclude(
1465
1505
  args?.include,
1466
1506
  fragment ? fragment._fields : undefined,
1467
1507
  listName,
@@ -1473,15 +1513,12 @@ function createGet<TPrisma extends PrismaClientLike>(
1473
1513
  // Virtual fields have no database column and must never reach Prisma (#628).
1474
1514
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1475
1515
 
1476
- // Try to find the record
1477
1516
  const item = await model.findFirst({
1478
1517
  where,
1479
1518
  include,
1480
1519
  })
1481
1520
 
1482
- // If record exists, return it
1483
1521
  if (item) {
1484
- // Filter readable fields and apply resolveOutput hooks
1485
1522
  const filtered = await filterReadableFields(
1486
1523
  item,
1487
1524
  listConfig.fields,
@@ -1493,21 +1530,19 @@ function createGet<TPrisma extends PrismaClientLike>(
1493
1530
  0,
1494
1531
  listName,
1495
1532
  declaredOnly,
1533
+ selection,
1496
1534
  )
1497
- // When a fragment is provided, pick only the requested fields from the result
1498
1535
  if (fragment) {
1499
1536
  return pickFields(filtered, fragment._fields)
1500
1537
  }
1501
1538
  return filtered
1502
1539
  }
1503
1540
 
1504
- // If no record and auto-create is enabled, create it
1505
1541
  if (shouldAutoCreate(listConfig)) {
1506
1542
  const defaultData = getDefaultData(listConfig)
1507
1543
  return await createFn({ data: defaultData })
1508
1544
  }
1509
1545
 
1510
- // No record and auto-create is disabled
1511
1546
  return null
1512
1547
  }
1513
1548
  }