@opensaas/stack-core 0.38.0 → 0.39.1

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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
@@ -5,8 +5,11 @@ import {
5
5
  mergeFilters,
6
6
  filterReadableFields,
7
7
  buildAccessScopedInclude,
8
+ buildAccessScopedWhere,
8
9
  stripVirtualFieldsFromInclude,
9
10
  foldDeclaredDependencies,
11
+ validateQueryKeys,
12
+ validateQueryFieldReadAccess,
10
13
  } from '../access/index.js'
11
14
  import type { DeclaredOnlyTree } from '../access/index.js'
12
15
  import { ValidationError, DatabaseError } from '../hooks/index.js'
@@ -21,29 +24,31 @@ import {
21
24
  updateWriteStrategy,
22
25
  deleteWriteStrategy,
23
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'
24
30
 
25
31
  export type ServerActionProps =
26
32
  | { listKey: string; action: 'create'; data: Record<string, unknown> }
27
33
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
28
34
  | { listKey: string; action: 'delete'; id: string }
29
35
  | { listKey: string; action: 'bulkDelete'; ids: string[] }
30
- // Custom list-specific Bulk action (issue #736). `key` names an action
31
- // declared in the list's `ui.listView.bulkActions`; the client only ever
32
- // sends this serialisable `{ key, ids }` the server-side `handler`
33
- // (never serialised) is looked up by `key` and run with the rebuilt secured
34
- // context over `ids`. Returns a distinct `{ bulkAction, message? }` shape so
35
- // a redirect-on-`success` wrapper never hijacks it (same rationale as
36
- // `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.
37
42
  | { listKey: string; action: 'bulkAction'; key: string; ids: string[] }
38
43
  // Relationship-table row removal (ADR-0018, #739). `listKey`/`id` target the
39
- // RELATED row, so the related list's own access control + hooks apply (never
40
- // the parent's). `mode: 'disconnect'` unlinks the row non-destructively by
41
- // disconnecting its back-reference (`field`; `parentId` is the record being
42
- // edited, needed only when that back-reference is to-many, e.g. a
43
- // many-to-many join); `mode: 'delete'` truly deletes the row. Like
44
- // `bulkDelete`, it returns a distinct `{ removed }` shape (never a
45
- // single-op `success`) so a UI wrapper that redirects on `success` the
46
- // 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.
47
52
  | {
48
53
  listKey: string
49
54
  action: 'removeRelated'
@@ -52,13 +57,10 @@ export type ServerActionProps =
52
57
  field?: string
53
58
  parentId?: string
54
59
  }
55
- // Relationship-table inline cell edit (issue #737). `listKey`/`id` target the
56
- // RELATED row and `field`/`value` a single scalar field on it, so the update
57
- // runs through the related list's OWN operation- and field-level access +
58
- // hooks/validation (never the parent's) the same ADR-0018 boundary as
59
- // `removeRelated`. It returns a distinct `{ updated }` shape (never a
60
- // single-op `success`) so a UI wrapper that redirects on `success` — the
61
- // 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.
62
64
  | {
63
65
  listKey: string
64
66
  action: 'updateRelated'
@@ -66,14 +68,12 @@ export type ServerActionProps =
66
68
  field: string
67
69
  value: unknown
68
70
  }
69
- // Relationship-table pre-linked create (issue #738). `listKey` targets the
70
- // RELATED list, so the related list's own create access control + hooks apply
71
- // (never the parent's) the same ADR-0018 boundary as `removeRelated`. The
72
- // back-reference to the parent is set on the SERVER from `field`/`parentId`
73
- // (never trusted from `data`), so the new row is linked to exactly the parent
74
- // being edited. It returns a distinct `{ created }` shape (never a single-op
75
- // `success`) so a UI wrapper that redirects on `success` — the item-form
76
- // 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.
77
77
  | {
78
78
  listKey: string
79
79
  action: 'createRelated'
@@ -90,21 +90,11 @@ export type ServerActionProps =
90
90
  selectedIds?: string[]
91
91
  }
92
92
 
93
- /**
94
- * Tracks which (listName, operation) pairs have already warned about an ignored
95
- * `select` argument, so a misused read op warns once rather than on every call.
96
- */
97
93
  const selectWarnings = new Set<string>()
98
94
 
99
95
  /**
100
- * Warn (once per list+operation) when a caller passes a `select` argument to a
101
- * read op that does not honour it.
102
- *
103
- * `context.db` reads never apply Prisma `select` semantics — narrowing is done
104
- * via `include` or a fragment `query`. The op still runs and returns the full,
105
- * access-filtered result, so this is a visible no-op rather than an error.
106
- *
107
- * 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.
108
98
  */
109
99
  function warnIfSelectIgnored(
110
100
  args: { select?: unknown } | undefined,
@@ -125,9 +115,6 @@ function warnIfSelectIgnored(
125
115
  )
126
116
  }
127
117
 
128
- /**
129
- * Check if a list is configured as a singleton
130
- */
131
118
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
132
119
  function isSingletonList(listConfig: ListConfig<any>): boolean {
133
120
  return !!listConfig.isSingleton
@@ -135,25 +122,17 @@ function isSingletonList(listConfig: ListConfig<any>): boolean {
135
122
 
136
123
  /**
137
124
  * Compute the set of single-field unique selectors a `findUnique` `where` may be
138
- * keyed by, derived from what the list config exposes at runtime.
139
- *
140
- * The set is:
141
- * - `id` always a unique identifier on every list.
142
- * - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
143
- * - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
144
- * column name (`<field>Id`) — that is the column Prisma marks `@unique`, so the
145
- * unique `where` is keyed by `<field>Id`, not the relation field itself.
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.
146
129
  *
147
- * Chosen rule (documented intentionally): the config does NOT expose compound
148
- * (`@@unique`) keys at runtime there is no list-level unique declaration in the
149
- * config API so we cannot validate compound `<Model>_<a>_<b>` selectors. We
150
- * therefore enforce the tractable subset: `where` must contain EXACTLY ONE
151
- * recognised single-field unique key and NO other keys. This rejects non-unique
152
- * filters (the bug in #567) and rejects extra non-unique keys alongside a unique
153
- * one, while never falsely rejecting a valid single-field unique lookup. If a
154
- * project legitimately needs a compound-unique lookup, that path is not covered
155
- * here and would need explicit config support; the safe escape hatch for any
156
- * 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).
157
136
  */
158
137
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
159
138
  function getUniqueWhereKeys(listConfig: ListConfig<any>): Set<string> {
@@ -199,10 +178,6 @@ function assertUniqueWhere(
199
178
  }
200
179
  }
201
180
 
202
- /**
203
- * Check if auto-create is enabled for a singleton list
204
- * Defaults to true if not explicitly set to false
205
- */
206
181
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
207
182
  function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
208
183
  if (!listConfig.isSingleton) return false
@@ -210,22 +185,15 @@ function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
210
185
  return listConfig.isSingleton.autoCreate !== false
211
186
  }
212
187
 
213
- /**
214
- * Extract default values from field configs
215
- * Used to auto-create singleton records with sensible defaults
216
- */
217
188
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
218
189
  function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
219
190
  const data: Record<string, unknown> = {}
220
191
 
221
192
  for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
222
- // Skip virtual fields - they're not stored in database
223
193
  if (fieldConfig.virtual) continue
224
194
 
225
- // Skip system fields (id, createdAt, updatedAt)
226
195
  if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
227
196
 
228
- // Add default value if present
229
197
  if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
230
198
  data[fieldKey] = fieldConfig.defaultValue
231
199
  }
@@ -234,12 +202,8 @@ function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
234
202
  return data
235
203
  }
236
204
 
237
- /**
238
- * Parse Prisma error and convert to user-friendly DatabaseError
239
- */
240
205
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
241
206
  function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
242
- // Check if it's a Prisma error
243
207
  if (
244
208
  error &&
245
209
  typeof error === 'object' &&
@@ -249,15 +213,13 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
249
213
  ) {
250
214
  const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
251
215
 
252
- // Handle unique constraint violation
216
+ // P2002 is Prisma's unique constraint violation code.
253
217
  if (prismaError.code === 'P2002') {
254
218
  const target = prismaError.meta?.target
255
219
  const fieldErrors: Record<string, string> = {}
256
220
 
257
221
  if (target && Array.isArray(target)) {
258
- // Get field names from the constraint target
259
222
  for (const fieldName of target) {
260
- // Get the field config to get a better label
261
223
  const fieldConfig = listConfig.fields[fieldName]
262
224
  const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
263
225
 
@@ -268,7 +230,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
268
230
  }
269
231
  }
270
232
 
271
- // Create a user-friendly general message
272
233
  const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
273
234
  return new DatabaseError(
274
235
  `${fieldLabels} must be unique. The value you entered is already in use.`,
@@ -280,7 +241,6 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
280
241
  return new DatabaseError('A record with this value already exists', {}, prismaError.code)
281
242
  }
282
243
 
283
- // Handle other Prisma errors - return generic message
284
244
  return new DatabaseError(
285
245
  prismaError.message || 'A database error occurred',
286
246
  {},
@@ -288,22 +248,16 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
288
248
  )
289
249
  }
290
250
 
291
- // Not a Prisma error, return as-is if it's already an Error
292
251
  if (error instanceof Error) {
293
252
  return error
294
253
  }
295
254
 
296
- // Unknown error type
297
255
  return new Error('An unknown error occurred')
298
256
  }
299
257
 
300
258
  /**
301
- * Database transaction isolation levels.
302
- *
303
- * Mirrors Prisma's `TransactionIsolationLevel`. The level passed to
304
- * {@link StackContext.transaction} is forwarded to the underlying interactive
305
- * transaction; provider support varies (e.g. `Serializable` is supported by
306
- * PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
259
+ * Mirrors Prisma's `TransactionIsolationLevel`. Provider support varies —
260
+ * e.g. `Serializable` requires PostgreSQL.
307
261
  */
308
262
  export type TransactionIsolationLevel =
309
263
  'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable' | 'Snapshot'
@@ -379,13 +333,34 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
379
333
  }
380
334
 
381
335
  /**
382
- * Create an access-controlled context
383
- *
384
- * @param config - OpenSaas configuration
385
- * @param prisma - Your Prisma client instance (pass as generic for type safety)
386
- * @param session - Current session object (or null if not authenticated)
387
- * @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.
388
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
+
389
364
  export function getContext<
390
365
  TConfig extends OpenSaasConfig,
391
366
  TPrisma extends PrismaClientLike = PrismaClientLike,
@@ -398,13 +373,14 @@ export function getContext<
398
373
  // Internal: when rebuilding the context against a transaction client, reuse the
399
374
  // already-initialised plugin services rather than re-running plugin runtimes.
400
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,
401
380
  ): StackContext<TPrisma> {
402
- // Initialize db object - will be populated with access-controlled operations
403
- // Type is intentionally broad to allow dynamic model access
381
+ // Broad type to allow dynamic model access; populated by populateDbDelegate below.
404
382
  const db: Record<string, unknown> = {}
405
383
 
406
- // Create context with db reference (will be populated below)
407
- // Storage utilities can be provided via parameter or use default stubs
408
384
  const context: AccessContext<TPrisma> = {
409
385
  session,
410
386
  prisma: prisma as TPrisma,
@@ -436,15 +412,13 @@ export function getContext<
436
412
  plugins: _sharedPlugins ?? {},
437
413
  _isSudo,
438
414
  _resolveOutputChain: [],
415
+ _transactionOwner,
439
416
  }
440
417
 
441
- // Create access-controlled operations for each list, populating `db` in place.
442
418
  populateDbDelegate(db, config, prisma, context)
443
419
 
444
- // Execute plugin runtime functions and populate context.plugins.
445
420
  // Skipped when reusing shared plugins (transaction rebind) so runtimes — and
446
421
  // any side effects they carry — run exactly once per top-level context.
447
- // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
448
422
  if (!_sharedPlugins) {
449
423
  const pluginsToExecute = config._plugins || config.plugins || []
450
424
  for (const plugin of pluginsToExecute) {
@@ -458,40 +432,24 @@ export function getContext<
458
432
  )
459
433
  } catch (error) {
460
434
  console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
461
- // Continue with other plugins even if one fails
462
435
  }
463
436
  }
464
437
  }
465
438
  }
466
439
 
467
- // Generic server action handler with discriminated union for type safety
468
- // 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.
469
442
  async function serverAction(props: ServerActionProps): Promise<
470
443
  | { success: true; data: unknown }
471
444
  | { success: false; error: string; fieldErrors?: Record<string, string> }
472
- // Bulk actions report a count rather than a single-op `success` flag: the
473
- // shape is deliberately distinct so a UI wrapper that redirects on a
474
- // single-item `success` (the item-form pattern) does not hijack a
475
- // 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.
476
448
  | { deleted: number; total: number }
477
- // Relationship-table row removal reports `removed` (with an optional reason)
478
- // rather than `success` — same distinct-shape rationale as `bulkDelete`, so
479
- // an in-place removal never triggers a redirect-on-success wrapper.
480
449
  | { removed: boolean; error?: string }
481
- // Relationship-table pre-linked create reports `created` (with the new row's
482
- // id, or an error + fieldErrors for the drawer) rather than `success` — same
483
- // distinct-shape rationale, so an in-place create never triggers a
484
- // redirect-on-success wrapper.
485
450
  | { created: boolean; id?: string; error?: string; fieldErrors?: Record<string, string> }
486
- // Custom Bulk action (issue #736) reports `bulkAction` with the handler's
487
- // optional `message` (success) or an `error` (not found / denied / threw) —
488
- // again a distinct shape from single-op `success`.
489
451
  | { bulkAction: true; message?: string }
490
452
  | { bulkAction: false; error: string }
491
- // Relationship-table inline cell edit reports `updated` (with an optional
492
- // reason + fieldErrors for the edited cell) rather than `success` — same
493
- // distinct-shape rationale, so an in-place cell edit never triggers a
494
- // redirect-on-success wrapper.
495
453
  | { updated: boolean; error?: string; fieldErrors?: Record<string, string> }
496
454
  > {
497
455
  const dbKey = getDbKey(props.listKey)
@@ -578,8 +536,7 @@ export function getContext<
578
536
  }
579
537
  }
580
538
 
581
- // Relationship-table row removal (ADR-0018, #739). Runs on the RELATED row
582
- // through the secured context, so the related list's access + hooks apply.
539
+ // Runs on the RELATED row (ADR-0018 boundary see ServerActionProps above).
583
540
  // Honours Silent failure: an access-denied operation returns `null`, which
584
541
  // becomes `{ removed: false }` with a generic reason — never leaking whether
585
542
  // the row was denied or absent.
@@ -620,14 +577,13 @@ export function getContext<
620
577
  }
621
578
  }
622
579
 
623
- // Relationship-table pre-linked create (ADR-0018, #738). Creates a row on
624
- // the RELATED list through the secured context, so the related list's create
625
- // access + hooks (and field-level access) apply never the parent's. The
626
- // back-reference to the parent is set here from `field`/`parentId` (a to-one
627
- // back-ref connects a single parent; a to-many back-ref, e.g. many-to-many,
628
- // connects the parent by id), so the client can never re-target the link.
629
- // Honours Silent failure: an access-denied create returns `null`, surfaced
630
- // 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).
631
587
  if (props.action === 'createRelated') {
632
588
  try {
633
589
  // Defensive guard (hardening; unreachable from the drawer, which always
@@ -685,11 +641,9 @@ export function getContext<
685
641
  }
686
642
  }
687
643
 
688
- // Relationship-table inline cell edit (ADR-0018, #737). Updates ONE scalar
689
- // field on the RELATED row through the secured context, so the related list's
690
- // operation- and field-level update access plus its hooks/validation apply
691
- // never the parent's. Honours Silent failure: an access-denied update returns
692
- // `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
693
647
  // denied-vs-absent leak); a validation/db error surfaces its message and
694
648
  // fieldErrors so the cell can revert with a reason and show an inline error.
695
649
  if (props.action === 'updateRelated') {
@@ -764,7 +718,6 @@ export function getContext<
764
718
  data: result,
765
719
  }
766
720
  } catch (error) {
767
- // Handle ValidationError (has fieldErrors)
768
721
  if (error instanceof ValidationError) {
769
722
  return {
770
723
  success: false,
@@ -773,7 +726,6 @@ export function getContext<
773
726
  }
774
727
  }
775
728
 
776
- // Handle DatabaseError (has fieldErrors)
777
729
  if (error instanceof DatabaseError) {
778
730
  return {
779
731
  success: false,
@@ -782,7 +734,6 @@ export function getContext<
782
734
  }
783
735
  }
784
736
 
785
- // Parse and convert Prisma errors to user-friendly DatabaseError
786
737
  const dbError = parsePrismaError(error, listConfig)
787
738
  if (dbError instanceof DatabaseError) {
788
739
  return {
@@ -792,7 +743,6 @@ export function getContext<
792
743
  }
793
744
  }
794
745
 
795
- // Generic error fallback
796
746
  return {
797
747
  success: false,
798
748
  error: dbError.message,
@@ -800,34 +750,77 @@ export function getContext<
800
750
  }
801
751
  }
802
752
 
803
- // Sudo function - creates a new context that bypasses access control
804
- // but still executes all hooks and validation
753
+ // Bypasses access control; hooks and validation still run.
805
754
  function sudo(): StackContext<TPrisma> {
806
- 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
+ )
807
766
  }
808
767
 
809
- // Interactive, hook-firing transaction (#614). Rebinds the access-controlled
810
- // context to the transaction client so every `txContext.db.*` write runs its
811
- // access checks + hooks but persists inside ONE transaction (atomic). The
812
- // transaction `options` (e.g. `isolationLevel`) pass through to Prisma, and a
813
- // serialization failure thrown inside the callback propagates to the caller
814
- // 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.
815
779
  function transaction<T>(
816
780
  fn: (txContext: StackContext<TPrisma>) => Promise<T>,
817
781
  options?: TransactionOptions,
818
782
  ): Promise<T> {
819
- const client = prisma as unknown as TransactionCapable<TPrisma>
820
- if (typeof client.$transaction !== 'function') {
821
- // No interactive transaction available — either a plain client/mock or we
822
- // are already inside a transaction (a Prisma tx client exposes no
823
- // `$transaction`). Run directly: hook/access semantics are identical and
824
- // atomicity is provided by any enclosing transaction.
783
+ if (context._transactionOwner) {
825
784
  return fn(returned)
826
785
  }
827
- return client.$transaction(
828
- (tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins)),
829
- options,
830
- ) 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)
831
824
  }
832
825
 
833
826
  const returned: StackContext<TPrisma> = {
@@ -864,7 +857,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
864
857
  for (const [listName, listConfig] of Object.entries(config.lists)) {
865
858
  const dbKey = getDbKey(listName)
866
859
 
867
- // Create base operations
868
860
  const createOp = createCreate(listName, listConfig, prisma, context, config)
869
861
  const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
870
862
  const updateOp = createUpdate(listName, listConfig, prisma, context, config)
@@ -875,7 +867,7 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
875
867
  create: createOp,
876
868
  update: updateOp,
877
869
  delete: createDelete(listName, listConfig, prisma, context, config),
878
- count: createCount(listName, listConfig, prisma, context),
870
+ count: createCount(listName, listConfig, prisma, context, config),
879
871
  createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
880
872
  updateMany: createUpdateMany(
881
873
  listName,
@@ -888,7 +880,6 @@ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
888
880
  ),
889
881
  }
890
882
 
891
- // Add get() method for singleton lists
892
883
  if (isSingletonList(listConfig)) {
893
884
  operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
894
885
  }
@@ -981,9 +972,6 @@ async function resolveReadInclude(
981
972
  return { include, declaredOnly: folded.declaredOnly, selection: undefined }
982
973
  }
983
974
 
984
- /**
985
- * Create findUnique operation with access control
986
- */
987
975
  function createFindUnique<TPrisma extends PrismaClientLike>(
988
976
  listName: string,
989
977
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1003,19 +991,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1003
991
  // `select` is not honoured — accepted only so the no-op can be made visible.
1004
992
  select?: Record<string, unknown>
1005
993
  }) => {
1006
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
1007
994
  warnIfSelectIgnored(args, listName, 'findUnique')
1008
995
 
1009
- // Enforce unique-`where` (Keystone `findOne` parity). This is a caller-shape
1010
- // check independent of access, so it runs first and THROWS on misuse — it is
1011
- // not an access denial and must not be masked as a silent `null`. The
1012
- // type-level constraint already lives on the generated delegate: the custom
1013
- // `<List>FindUniqueArgs` only Omits `select`/`include` from
1014
- // `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
1015
- // `<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.
1016
1001
  assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName)
1017
1002
 
1018
- // Check query access (skip if sudo mode)
1019
1003
  let where: Record<string, unknown> = args.where
1020
1004
  if (!context._isSudo) {
1021
1005
  const queryAccess = listConfig.access?.operation?.query
@@ -1028,7 +1012,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1028
1012
  return null
1029
1013
  }
1030
1014
 
1031
- // Merge access filter with where clause
1032
1015
  const mergedWhere = mergeFilters(args.where, accessResult)
1033
1016
  if (mergedWhere === null) {
1034
1017
  return null
@@ -1036,9 +1019,8 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1036
1019
  where = mergedWhere
1037
1020
  }
1038
1021
 
1039
- // When a query fragment is provided, build the include from the fragment
1040
- // instead of the access-controlled include. Access control still runs via
1041
- // 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.
1042
1024
  const fragment = isFragment(args.query) ? args.query : null
1043
1025
 
1044
1026
  // Resolve `include`, folding any declared dependencies (`needs`,
@@ -1062,7 +1044,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1062
1044
  // `undefined`), only the ones a fragment named otherwise.
1063
1045
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1064
1046
 
1065
- // Execute query with optimized includes
1066
1047
  // Access Prisma model dynamically - required because model names are generated at runtime
1067
1048
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1068
1049
  const model = (prisma as any)[getDbKey(listName)]
@@ -1075,7 +1056,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1075
1056
  return null
1076
1057
  }
1077
1058
 
1078
- // Filter readable fields and apply resolveOutput hooks (including nested relationships)
1079
1059
  // Pass sudo flag through context to skip field-level access checks
1080
1060
  const filtered = await filterReadableFields(
1081
1061
  item,
@@ -1091,7 +1071,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1091
1071
  selection,
1092
1072
  )
1093
1073
 
1094
- // When a fragment is provided, pick only the requested fields from the result
1095
1074
  if (fragment) {
1096
1075
  return pickFields(filtered, fragment._fields)
1097
1076
  }
@@ -1100,9 +1079,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1100
1079
  }
1101
1080
  }
1102
1081
 
1103
- /**
1104
- * Create findMany operation with access control
1105
- */
1106
1082
  function createFindMany<TPrisma extends PrismaClientLike>(
1107
1083
  listName: string,
1108
1084
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1122,10 +1098,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1122
1098
  // `select` is not honoured — accepted only so the no-op can be made visible.
1123
1099
  select?: Record<string, unknown>
1124
1100
  }) => {
1125
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
1126
1101
  warnIfSelectIgnored(args, listName, 'findMany')
1127
1102
 
1128
- // 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.
1129
1105
  if (isSingletonList(listConfig)) {
1130
1106
  throw new ValidationError(
1131
1107
  [`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`],
@@ -1133,7 +1109,8 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1133
1109
  )
1134
1110
  }
1135
1111
 
1136
- // 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.
1137
1114
  let where: Record<string, unknown> | undefined = args?.where
1138
1115
  if (!context._isSudo) {
1139
1116
  const queryAccess = listConfig.access?.operation?.query
@@ -1146,15 +1123,56 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1146
1123
  return []
1147
1124
  }
1148
1125
 
1149
- // Merge access filter with where clause
1150
- 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)
1151
1170
  if (mergedWhere === null) {
1152
1171
  return []
1153
1172
  }
1154
1173
  where = mergedWhere
1155
1174
  }
1156
1175
 
1157
- // When a query fragment is provided, build include from fragment fields
1158
1176
  const fragment = isFragment(args?.query) ? args.query : null
1159
1177
 
1160
1178
  // Resolve `include`, folding any declared dependencies (`needs`,
@@ -1169,16 +1187,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1169
1187
  config,
1170
1188
  )
1171
1189
 
1172
- // Virtual fields have no database column. Whichever path produced
1173
- // `include` (fragment, access-controlled merge, or sudo passthrough), a
1174
- // virtual key must never reach Prisma — it would throw "Unknown field"
1175
- // (#628). Below, `filterReadableFields` computes a virtual field's value
1176
- // exactly when `selection` says the read is going to return it (ADR-0027)
1177
- // — every one of them for a bare/`include`-based read (`selection` is
1178
- // `undefined`), only the ones a fragment named otherwise.
1190
+ // Strips virtual keys from `include` before the Prisma call — see the
1191
+ // `createFindUnique` comment above for why (#628, ADR-0027).
1179
1192
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1180
1193
 
1181
- // Execute query with optimized includes
1182
1194
  // Access Prisma model dynamically - required because model names are generated at runtime
1183
1195
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1184
1196
  const model = (prisma as any)[getDbKey(listName)]
@@ -1190,7 +1202,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1190
1202
  include,
1191
1203
  })
1192
1204
 
1193
- // Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
1194
1205
  // Pass sudo flag through context to skip field-level access checks
1195
1206
  const filtered = await Promise.all(
1196
1207
  items.map((item: Record<string, unknown>) =>
@@ -1210,7 +1221,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1210
1221
  ),
1211
1222
  )
1212
1223
 
1213
- // When a fragment is provided, pick only the requested fields from each result
1214
1224
  if (fragment) {
1215
1225
  return filtered.map((item: Record<string, unknown>) => pickFields(item, fragment._fields))
1216
1226
  }
@@ -1244,9 +1254,6 @@ function createFindFirst(findManyOp: ReturnType<typeof createFindMany>) {
1244
1254
  }
1245
1255
  }
1246
1256
 
1247
- /**
1248
- * Create create operation with access control and hooks
1249
- */
1250
1257
  function createCreate<TPrisma extends PrismaClientLike>(
1251
1258
  listName: string,
1252
1259
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1270,10 +1277,8 @@ function createCreate<TPrisma extends PrismaClientLike>(
1270
1277
  }
1271
1278
  }
1272
1279
 
1273
- /**
1274
- * Create createMany operation with access control and hooks
1275
- * Runs create in a loop to ensure all hooks and access control are executed for each item
1276
- */
1280
+ // Runs create in a loop (not Prisma's native createMany) so every item still
1281
+ // gets its own hooks and access control.
1277
1282
  function createCreateMany<TPrisma extends PrismaClientLike>(
1278
1283
  listName: string,
1279
1284
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1296,9 +1301,6 @@ function createCreateMany<TPrisma extends PrismaClientLike>(
1296
1301
  }
1297
1302
  }
1298
1303
 
1299
- /**
1300
- * Create update operation with access control and hooks
1301
- */
1302
1304
  function createUpdate<TPrisma extends PrismaClientLike>(
1303
1305
  listName: string,
1304
1306
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1322,10 +1324,8 @@ function createUpdate<TPrisma extends PrismaClientLike>(
1322
1324
  }
1323
1325
  }
1324
1326
 
1325
- /**
1326
- * Create updateMany operation with access control and hooks
1327
- * Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
1328
- */
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.
1329
1329
  function createUpdateMany<TPrisma extends PrismaClientLike>(
1330
1330
  listName: string,
1331
1331
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1339,10 +1339,8 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
1339
1339
  updateFn: any,
1340
1340
  ) {
1341
1341
  return async (args: { where?: Record<string, unknown>; data: Record<string, unknown> }) => {
1342
- // First, find all matching records (respects access control)
1343
1342
  const items = await findManyFn({ where: args.where })
1344
1343
 
1345
- // Then update each one individually (runs hooks and access control for each)
1346
1344
  const results = []
1347
1345
  for (const item of items) {
1348
1346
  const result = await updateFn({ where: { id: item.id }, data: args.data })
@@ -1353,9 +1351,6 @@ function createUpdateMany<TPrisma extends PrismaClientLike>(
1353
1351
  }
1354
1352
  }
1355
1353
 
1356
- /**
1357
- * Create delete operation with access control and hooks
1358
- */
1359
1354
  function createDelete<TPrisma extends PrismaClientLike>(
1360
1355
  listName: string,
1361
1356
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1379,18 +1374,17 @@ function createDelete<TPrisma extends PrismaClientLike>(
1379
1374
  }
1380
1375
  }
1381
1376
 
1382
- /**
1383
- * Create count operation with access control
1384
- */
1385
1377
  function createCount<TPrisma extends PrismaClientLike>(
1386
1378
  listName: string,
1387
1379
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
1388
1380
  listConfig: ListConfig<any>,
1389
1381
  prisma: TPrisma,
1390
1382
  context: AccessContext<TPrisma>,
1383
+ config: OpenSaasConfig,
1391
1384
  ) {
1392
1385
  return async (args?: { where?: Record<string, unknown> }) => {
1393
- // 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.
1394
1388
  let where: Record<string, unknown> | undefined = args?.where
1395
1389
  if (!context._isSudo) {
1396
1390
  const queryAccess = listConfig.access?.operation?.query
@@ -1403,15 +1397,50 @@ function createCount<TPrisma extends PrismaClientLike>(
1403
1397
  return 0
1404
1398
  }
1405
1399
 
1406
- // Merge access filter with where clause
1407
- 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)
1408
1438
  if (mergedWhere === null) {
1409
1439
  return 0
1410
1440
  }
1411
1441
  where = mergedWhere
1412
1442
  }
1413
1443
 
1414
- // Execute count
1415
1444
  // Access Prisma model dynamically - required because model names are generated at runtime
1416
1445
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1417
1446
  const model = (prisma as any)[getDbKey(listName)]
@@ -1423,10 +1452,6 @@ function createCount<TPrisma extends PrismaClientLike>(
1423
1452
  }
1424
1453
  }
1425
1454
 
1426
- /**
1427
- * Create get operation for singleton lists
1428
- * Returns the single record, or auto-creates it if enabled
1429
- */
1430
1455
  function createGet<TPrisma extends PrismaClientLike>(
1431
1456
  listName: string,
1432
1457
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -1444,15 +1469,12 @@ function createGet<TPrisma extends PrismaClientLike>(
1444
1469
  // `select` is not honoured — accepted only so the no-op can be made visible.
1445
1470
  select?: Record<string, unknown>
1446
1471
  }) => {
1447
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
1448
1472
  warnIfSelectIgnored(args, listName, 'get')
1449
1473
 
1450
- // First try to find the existing record
1451
1474
  // Access Prisma model dynamically - required because model names are generated at runtime
1452
1475
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1453
1476
  const model = (prisma as any)[getDbKey(listName)]
1454
1477
 
1455
- // Check query access (skip if sudo mode)
1456
1478
  let where: Record<string, unknown> = {}
1457
1479
  if (!context._isSudo) {
1458
1480
  const queryAccess = listConfig.access?.operation?.query
@@ -1465,15 +1487,15 @@ function createGet<TPrisma extends PrismaClientLike>(
1465
1487
  return null
1466
1488
  }
1467
1489
 
1468
- // 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`.
1469
1492
  if (accessResult && typeof accessResult === 'object') {
1470
1493
  where = accessResult
1471
1494
  }
1472
1495
  }
1473
1496
 
1474
- // When a query fragment is provided, build the include from the fragment
1475
- // instead of the access-controlled include. Access control still runs via
1476
- // 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.
1477
1499
  const fragment = isFragment(args?.query) ? args.query : null
1478
1500
 
1479
1501
  // Resolve `include`, folding any declared dependencies (`needs`,
@@ -1491,15 +1513,12 @@ function createGet<TPrisma extends PrismaClientLike>(
1491
1513
  // Virtual fields have no database column and must never reach Prisma (#628).
1492
1514
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1493
1515
 
1494
- // Try to find the record
1495
1516
  const item = await model.findFirst({
1496
1517
  where,
1497
1518
  include,
1498
1519
  })
1499
1520
 
1500
- // If record exists, return it
1501
1521
  if (item) {
1502
- // Filter readable fields and apply resolveOutput hooks
1503
1522
  const filtered = await filterReadableFields(
1504
1523
  item,
1505
1524
  listConfig.fields,
@@ -1513,20 +1532,17 @@ function createGet<TPrisma extends PrismaClientLike>(
1513
1532
  declaredOnly,
1514
1533
  selection,
1515
1534
  )
1516
- // When a fragment is provided, pick only the requested fields from the result
1517
1535
  if (fragment) {
1518
1536
  return pickFields(filtered, fragment._fields)
1519
1537
  }
1520
1538
  return filtered
1521
1539
  }
1522
1540
 
1523
- // If no record and auto-create is enabled, create it
1524
1541
  if (shouldAutoCreate(listConfig)) {
1525
1542
  const defaultData = getDefaultData(listConfig)
1526
1543
  return await createFn({ data: defaultData })
1527
1544
  }
1528
1545
 
1529
- // No record and auto-create is disabled
1530
1546
  return null
1531
1547
  }
1532
1548
  }