@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
@@ -1,23 +1,15 @@
1
- import { checkAccess, mergeFilters, filterReadableFields, buildIncludeWithAccessControl, mergeIncludeWithAccessControl, stripVirtualFieldsFromInclude, foldDeclaredDependencies, } from '../access/index.js';
1
+ import { checkAccess, mergeFilters, filterReadableFields, buildAccessScopedInclude, buildAccessScopedWhere, stripVirtualFieldsFromInclude, foldDeclaredDependencies, validateQueryKeys, validateQueryFieldReadAccess, } from '../access/index.js';
2
2
  import { ValidationError, DatabaseError } from '../hooks/index.js';
3
3
  import { getDbKey } from '../lib/case-utils.js';
4
- import { buildInclude, pickFields, isFragment } from '../query/index.js';
4
+ import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js';
5
5
  import { getRelationshipOptions } from '../query/relationship-options.js';
6
6
  import { runWritePipeline, createWriteStrategy, updateWriteStrategy, deleteWriteStrategy, } from './write-pipeline.js';
7
- /**
8
- * Tracks which (listName, operation) pairs have already warned about an ignored
9
- * `select` argument, so a misused read op warns once rather than on every call.
10
- */
7
+ import { AfterTransactionError } from './transaction-boundary.js';
8
+ import { TransactionRegistry } from '../access/transaction-registry.js';
11
9
  const selectWarnings = new Set();
12
10
  /**
13
- * Warn (once per list+operation) when a caller passes a `select` argument to a
14
- * read op that does not honour it.
15
- *
16
- * `context.db` reads never apply Prisma `select` semantics — narrowing is done
17
- * via `include` or a fragment `query`. The op still runs and returns the full,
18
- * access-filtered result, so this is a visible no-op rather than an error.
19
- *
20
- * Centralised here so every affected read op shares one implementation.
11
+ * Warn once per (list, operation) when a caller passes `select` to a read op
12
+ * that ignores it. See "Narrowing Reads" in packages/core/CLAUDE.md.
21
13
  */
22
14
  function warnIfSelectIgnored(args, listName, operation) {
23
15
  if (!args || args.select === undefined)
@@ -31,34 +23,23 @@ function warnIfSelectIgnored(args, listName, operation) {
31
23
  `Narrow a read with \`include\` or a fragment \`query\` instead. ` +
32
24
  `See https://stack.opensaas.au/docs/concepts/queries`);
33
25
  }
34
- /**
35
- * Check if a list is configured as a singleton
36
- */
37
26
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
38
27
  function isSingletonList(listConfig) {
39
28
  return !!listConfig.isSingleton;
40
29
  }
41
30
  /**
42
31
  * Compute the set of single-field unique selectors a `findUnique` `where` may be
43
- * keyed by, derived from what the list config exposes at runtime.
44
- *
45
- * The set is:
46
- * - `id` always a unique identifier on every list.
47
- * - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
48
- * - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
49
- * column name (`<field>Id`) — that is the column Prisma marks `@unique`, so the
50
- * unique `where` is keyed by `<field>Id`, not the relation field itself.
32
+ * keyed by, derived from what the list config exposes at runtime: `id`, plus any
33
+ * field declared `isIndexed: 'unique'` — for a `relationship` field, this is the
34
+ * foreign-key column name (`<field>Id`), since that's the column Prisma marks
35
+ * `@unique`, not the relation field itself.
51
36
  *
52
- * Chosen rule (documented intentionally): the config does NOT expose compound
53
- * (`@@unique`) keys at runtime there is no list-level unique declaration in the
54
- * config API so we cannot validate compound `<Model>_<a>_<b>` selectors. We
55
- * therefore enforce the tractable subset: `where` must contain EXACTLY ONE
56
- * recognised single-field unique key and NO other keys. This rejects non-unique
57
- * filters (the bug in #567) and rejects extra non-unique keys alongside a unique
58
- * one, while never falsely rejecting a valid single-field unique lookup. If a
59
- * project legitimately needs a compound-unique lookup, that path is not covered
60
- * here and would need explicit config support; the safe escape hatch for any
61
- * non-unique single-row lookup is `findFirst` (see #565).
37
+ * The config exposes no list-level compound (`@@unique`) declaration, so this
38
+ * cannot validate a compound `<Model>_<a>_<b>` selector `where` must contain
39
+ * exactly one recognised single-field unique key and no others. This rejects
40
+ * non-unique filters (#567) without ever rejecting a valid single-field unique
41
+ * lookup; a compound-unique or otherwise non-unique lookup should use
42
+ * `findFirst` instead (see #565).
62
43
  */
63
44
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
64
45
  function getUniqueWhereKeys(listConfig) {
@@ -95,10 +76,6 @@ function assertUniqueWhere(where, uniqueKeys, listName) {
95
76
  throw new ValidationError([message], {});
96
77
  }
97
78
  }
98
- /**
99
- * Check if auto-create is enabled for a singleton list
100
- * Defaults to true if not explicitly set to false
101
- */
102
79
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
103
80
  function shouldAutoCreate(listConfig) {
104
81
  if (!listConfig.isSingleton)
@@ -107,47 +84,34 @@ function shouldAutoCreate(listConfig) {
107
84
  return true;
108
85
  return listConfig.isSingleton.autoCreate !== false;
109
86
  }
110
- /**
111
- * Extract default values from field configs
112
- * Used to auto-create singleton records with sensible defaults
113
- */
114
87
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
115
88
  function getDefaultData(listConfig) {
116
89
  const data = {};
117
90
  for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
118
- // Skip virtual fields - they're not stored in database
119
91
  if (fieldConfig.virtual)
120
92
  continue;
121
- // Skip system fields (id, createdAt, updatedAt)
122
93
  if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt')
123
94
  continue;
124
- // Add default value if present
125
95
  if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
126
96
  data[fieldKey] = fieldConfig.defaultValue;
127
97
  }
128
98
  }
129
99
  return data;
130
100
  }
131
- /**
132
- * Parse Prisma error and convert to user-friendly DatabaseError
133
- */
134
101
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
135
102
  function parsePrismaError(error, listConfig) {
136
- // Check if it's a Prisma error
137
103
  if (error &&
138
104
  typeof error === 'object' &&
139
105
  'code' in error &&
140
106
  'meta' in error &&
141
107
  typeof error.code === 'string') {
142
108
  const prismaError = error;
143
- // Handle unique constraint violation
109
+ // P2002 is Prisma's unique constraint violation code.
144
110
  if (prismaError.code === 'P2002') {
145
111
  const target = prismaError.meta?.target;
146
112
  const fieldErrors = {};
147
113
  if (target && Array.isArray(target)) {
148
- // Get field names from the constraint target
149
114
  for (const fieldName of target) {
150
- // Get the field config to get a better label
151
115
  const fieldConfig = listConfig.fields[fieldName];
152
116
  const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
153
117
  if (fieldConfig) {
@@ -157,39 +121,54 @@ function parsePrismaError(error, listConfig) {
157
121
  fieldErrors[fieldName] = `This value is already in use`;
158
122
  }
159
123
  }
160
- // Create a user-friendly general message
161
124
  const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ');
162
125
  return new DatabaseError(`${fieldLabels} must be unique. The value you entered is already in use.`, fieldErrors, prismaError.code);
163
126
  }
164
127
  return new DatabaseError('A record with this value already exists', {}, prismaError.code);
165
128
  }
166
- // Handle other Prisma errors - return generic message
167
129
  return new DatabaseError(prismaError.message || 'A database error occurred', {}, prismaError.code);
168
130
  }
169
- // Not a Prisma error, return as-is if it's already an Error
170
131
  if (error instanceof Error) {
171
132
  return error;
172
133
  }
173
- // Unknown error type
174
134
  return new Error('An unknown error occurred');
175
135
  }
176
136
  /**
177
- * Create an access-controlled context
178
- *
179
- * @param config - OpenSaas configuration
180
- * @param prisma - Your Prisma client instance (pass as generic for type safety)
181
- * @param session - Current session object (or null if not authenticated)
182
- * @param storage - Optional storage utilities (uploadFile, uploadImage, deleteFile, deleteImage)
137
+ * Drain a `context.transaction()` owner's deferral registry once its callback
138
+ * (and any real underlying transaction) has settled (ADR-0028). A transaction/
139
+ * callback error always wins — compensators still all run, but their errors
140
+ * are discarded in favor of re-surfacing the original, matching the Write
141
+ * Pipeline's `txError` precedence otherwise any deferred `afterTransaction`
142
+ * errors reject with {@link AfterTransactionError} even though the callback
143
+ * succeeded and the transaction committed.
183
144
  */
145
+ async function settleTransactionOwner(settled, registry) {
146
+ const errors = [];
147
+ let result;
148
+ try {
149
+ result = await settled;
150
+ }
151
+ catch (err) {
152
+ const outcome = { status: 'rolled-back', error: err };
153
+ await registry.drain(outcome, errors);
154
+ throw err;
155
+ }
156
+ await registry.drain({ status: 'committed' }, errors);
157
+ if (errors.length > 0) {
158
+ throw new AfterTransactionError(errors);
159
+ }
160
+ return result;
161
+ }
184
162
  export function getContext(config, prisma, session, storage, _isSudo = false,
185
163
  // Internal: when rebuilding the context against a transaction client, reuse the
186
164
  // already-initialised plugin services rather than re-running plugin runtimes.
187
- _sharedPlugins) {
188
- // Initialize db object - will be populated with access-controlled operations
189
- // Type is intentionally broad to allow dynamic model access
165
+ _sharedPlugins,
166
+ // Internal (ADR-0028, #899): when rebuilding the context for a transaction
167
+ // owner's callback body, carry the deferral registry so writes reached
168
+ // through this context join it instead of firing afterTransaction eagerly.
169
+ _transactionOwner) {
170
+ // Broad type to allow dynamic model access; populated by populateDbDelegate below.
190
171
  const db = {};
191
- // Create context with db reference (will be populated below)
192
- // Storage utilities can be provided via parameter or use default stubs
193
172
  const context = {
194
173
  session,
195
174
  prisma: prisma,
@@ -213,13 +192,11 @@ _sharedPlugins) {
213
192
  plugins: _sharedPlugins ?? {},
214
193
  _isSudo,
215
194
  _resolveOutputChain: [],
195
+ _transactionOwner,
216
196
  };
217
- // Create access-controlled operations for each list, populating `db` in place.
218
197
  populateDbDelegate(db, config, prisma, context);
219
- // Execute plugin runtime functions and populate context.plugins.
220
198
  // Skipped when reusing shared plugins (transaction rebind) so runtimes — and
221
199
  // any side effects they carry — run exactly once per top-level context.
222
- // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
223
200
  if (!_sharedPlugins) {
224
201
  const pluginsToExecute = config._plugins || config.plugins || [];
225
202
  for (const plugin of pluginsToExecute) {
@@ -231,13 +208,12 @@ _sharedPlugins) {
231
208
  }
232
209
  catch (error) {
233
210
  console.error(`Error executing runtime for plugin "${plugin.name}":`, error);
234
- // Continue with other plugins even if one fails
235
211
  }
236
212
  }
237
213
  }
238
214
  }
239
- // Generic server action handler with discriminated union for type safety
240
- // Returns a result object instead of throwing to work properly in Next.js production
215
+ // Returns a result object instead of throwing — required for server actions
216
+ // to work in Next.js production builds.
241
217
  async function serverAction(props) {
242
218
  const dbKey = getDbKey(props.listKey);
243
219
  const listConfig = config.lists[props.listKey];
@@ -317,8 +293,7 @@ _sharedPlugins) {
317
293
  return { bulkAction: false, error: 'Action failed' };
318
294
  }
319
295
  }
320
- // Relationship-table row removal (ADR-0018, #739). Runs on the RELATED row
321
- // through the secured context, so the related list's access + hooks apply.
296
+ // Runs on the RELATED row (ADR-0018 boundary see ServerActionProps above).
322
297
  // Honours Silent failure: an access-denied operation returns `null`, which
323
298
  // becomes `{ removed: false }` with a generic reason — never leaking whether
324
299
  // the row was denied or absent.
@@ -359,14 +334,13 @@ _sharedPlugins) {
359
334
  return { removed: false, error: dbError.message };
360
335
  }
361
336
  }
362
- // Relationship-table pre-linked create (ADR-0018, #738). Creates a row on
363
- // the RELATED list through the secured context, so the related list's create
364
- // access + hooks (and field-level access) apply never the parent's. The
365
- // back-reference to the parent is set here from `field`/`parentId` (a to-one
366
- // back-ref connects a single parent; a to-many back-ref, e.g. many-to-many,
367
- // connects the parent by id), so the client can never re-target the link.
368
- // Honours Silent failure: an access-denied create returns `null`, surfaced
369
- // as `{ created: false }` with a generic reason (no denied-vs-absent leak).
337
+ // Runs on the RELATED list (ADR-0018 boundary see ServerActionProps above).
338
+ // The back-reference to the parent is set here from `field`/`parentId` (a
339
+ // to-one back-ref connects a single parent; a to-many back-ref, e.g.
340
+ // many-to-many, connects the parent by id), so the client can never
341
+ // re-target the link. Honours Silent failure: an access-denied create
342
+ // returns `null`, surfaced as `{ created: false }` with a generic reason
343
+ // (no denied-vs-absent leak).
370
344
  if (props.action === 'createRelated') {
371
345
  try {
372
346
  // Defensive guard (hardening; unreachable from the drawer, which always
@@ -423,11 +397,9 @@ _sharedPlugins) {
423
397
  };
424
398
  }
425
399
  }
426
- // Relationship-table inline cell edit (ADR-0018, #737). Updates ONE scalar
427
- // field on the RELATED row through the secured context, so the related list's
428
- // operation- and field-level update access plus its hooks/validation apply
429
- // never the parent's. Honours Silent failure: an access-denied update returns
430
- // `null`, surfaced as `{ updated: false }` with a generic reason (no
400
+ // Updates ONE scalar field on the RELATED row (ADR-0018 boundary see
401
+ // ServerActionProps above). Honours Silent failure: an access-denied update
402
+ // returns `null`, surfaced as `{ updated: false }` with a generic reason (no
431
403
  // denied-vs-absent leak); a validation/db error surfaces its message and
432
404
  // fieldErrors so the cell can revert with a reason and show an inline error.
433
405
  if (props.action === 'updateRelated') {
@@ -498,7 +470,6 @@ _sharedPlugins) {
498
470
  };
499
471
  }
500
472
  catch (error) {
501
- // Handle ValidationError (has fieldErrors)
502
473
  if (error instanceof ValidationError) {
503
474
  return {
504
475
  success: false,
@@ -506,7 +477,6 @@ _sharedPlugins) {
506
477
  fieldErrors: error.fieldErrors,
507
478
  };
508
479
  }
509
- // Handle DatabaseError (has fieldErrors)
510
480
  if (error instanceof DatabaseError) {
511
481
  return {
512
482
  success: false,
@@ -514,7 +484,6 @@ _sharedPlugins) {
514
484
  fieldErrors: error.fieldErrors,
515
485
  };
516
486
  }
517
- // Parse and convert Prisma errors to user-friendly DatabaseError
518
487
  const dbError = parsePrismaError(error, listConfig);
519
488
  if (dbError instanceof DatabaseError) {
520
489
  return {
@@ -523,34 +492,44 @@ _sharedPlugins) {
523
492
  fieldErrors: dbError.fieldErrors,
524
493
  };
525
494
  }
526
- // Generic error fallback
527
495
  return {
528
496
  success: false,
529
497
  error: dbError.message,
530
498
  };
531
499
  }
532
500
  }
533
- // Sudo function - creates a new context that bypasses access control
534
- // but still executes all hooks and validation
501
+ // Bypasses access control; hooks and validation still run.
535
502
  function sudo() {
536
- return getContext(config, prisma, session, context.storage, true);
503
+ return getContext(config, prisma, session, context.storage, true, undefined,
504
+ // ADR-0028: a sudo write issued from inside an owned transaction (e.g.
505
+ // `tx.sudo().db.x.create()`) must still defer to that owner.
506
+ context._transactionOwner);
537
507
  }
538
- // Interactive, hook-firing transaction (#614). Rebinds the access-controlled
539
- // context to the transaction client so every `txContext.db.*` write runs its
540
- // access checks + hooks but persists inside ONE transaction (atomic). The
541
- // transaction `options` (e.g. `isolationLevel`) pass through to Prisma, and a
542
- // serialization failure thrown inside the callback propagates to the caller
543
- // for retry (it is never converted to a silent `null`).
508
+ // Interactive, hook-firing transaction (#614). See the `transaction` doc on
509
+ // `StackContext` above for the atomicity/isolation/retry contract.
510
+ //
511
+ // This call OWNS a deferral registry for its callback's writes (ADR-0028):
512
+ // it always observes when its own callback settles resolve/reject — even
513
+ // when the underlying client cannot open a real interactive transaction, so
514
+ // every `txContext.db.*` write defers its transaction-boundary bracket here
515
+ // instead of firing eagerly, and this call flushes them with the real
516
+ // outcome once the callback (and any real transaction) has settled. A
517
+ // `transaction()` nested inside another joins the outer owner's queue
518
+ // rather than creating a second one.
544
519
  function transaction(fn, options) {
545
- const client = prisma;
546
- if (typeof client.$transaction !== 'function') {
547
- // No interactive transaction available — either a plain client/mock or we
548
- // are already inside a transaction (a Prisma tx client exposes no
549
- // `$transaction`). Run directly: hook/access semantics are identical and
550
- // atomicity is provided by any enclosing transaction.
520
+ if (context._transactionOwner) {
551
521
  return fn(returned);
552
522
  }
553
- return client.$transaction((tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins)), options);
523
+ const registry = new TransactionRegistry();
524
+ const client = prisma;
525
+ const settled = typeof client.$transaction !== 'function'
526
+ ? // No interactive transaction available (plain client/mock, or already
527
+ // inside one — see `TransactionCapable` above). Run directly: hook/
528
+ // access semantics are identical, atomicity comes from the enclosing
529
+ // transaction.
530
+ fn(getContext(config, prisma, session, context.storage, _isSudo, context.plugins, registry))
531
+ : client.$transaction((tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins, registry)), options);
532
+ return settleTransactionOwner(settled, registry);
554
533
  }
555
534
  const returned = {
556
535
  db: db,
@@ -579,7 +558,6 @@ _sharedPlugins) {
579
558
  export function populateDbDelegate(target, config, prisma, context) {
580
559
  for (const [listName, listConfig] of Object.entries(config.lists)) {
581
560
  const dbKey = getDbKey(listName);
582
- // Create base operations
583
561
  const createOp = createCreate(listName, listConfig, prisma, context, config);
584
562
  const findManyOp = createFindMany(listName, listConfig, prisma, context, config);
585
563
  const updateOp = createUpdate(listName, listConfig, prisma, context, config);
@@ -590,11 +568,10 @@ export function populateDbDelegate(target, config, prisma, context) {
590
568
  create: createOp,
591
569
  update: updateOp,
592
570
  delete: createDelete(listName, listConfig, prisma, context, config),
593
- count: createCount(listName, listConfig, prisma, context),
571
+ count: createCount(listName, listConfig, prisma, context, config),
594
572
  createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
595
573
  updateMany: createUpdateMany(listName, listConfig, prisma, context, config, findManyOp, updateOp),
596
574
  };
597
- // Add get() method for singleton lists
598
575
  if (isSingletonList(listConfig)) {
599
576
  operations.get = createGet(listName, listConfig, prisma, context, config, createOp);
600
577
  }
@@ -619,51 +596,51 @@ export function buildDbDelegate(config, prisma, context) {
619
596
  *
620
597
  * A fragment's own `include` and a sudo caller's `include` are folded and
621
598
  * used as-is, matching their existing (unmerged) treatment. A non-sudo
622
- * caller include is folded and then merged through the same
623
- * access-scoping pipeline as before. A bare read stays on the exact
624
- * ADR-0024 path `include: undefined`, no related `query` access
625
- * evaluated unless folding actually added something, which only happens
626
- * when a field on this list declares `needs`.
599
+ * caller include is folded and then scoped by `buildAccessScopedInclude`
600
+ * (ADR-0026) caller-directed, so a relation named nowhere in the folded
601
+ * tree never has its list's `query` access evaluated at all. A bare read
602
+ * stays on the exact ADR-0024 path `include: undefined`, no related
603
+ * `query` access evaluated unless folding actually added something, which
604
+ * only happens when a field on this list declares `needs`.
605
+ *
606
+ * Also returns the `FieldSelectionScope` a fragment's own field selection
607
+ * produces (ADR-0027), so the caller can pass it to `filterReadableFields`
608
+ * and make computation itself projection-aware, not only the fold above.
609
+ * `undefined` for every non-fragment path: a caller `include` (sudo or not)
610
+ * and a bare read both mean "compute every field," matching what they
611
+ * already fetch.
627
612
  */
628
613
  async function resolveReadInclude(callerInclude, fragmentFields, listName,
629
614
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
630
615
  listConfig, context, config) {
631
616
  if (fragmentFields !== undefined) {
632
617
  const fragmentInclude = buildInclude(fragmentFields) ?? undefined;
633
- return foldDeclaredDependencies(fragmentInclude, listConfig.fields, config);
618
+ const selection = buildFieldSelectionScope(fragmentFields);
619
+ const folded = foldDeclaredDependencies(fragmentInclude, listConfig.fields, config, listName, [listName], selection);
620
+ return { ...folded, selection };
634
621
  }
635
622
  if (context._isSudo) {
636
- return foldDeclaredDependencies(callerInclude, listConfig.fields, config);
623
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName);
624
+ return { ...folded, selection: undefined };
637
625
  }
638
- const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config);
626
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName);
639
627
  if (!folded.include) {
640
- return folded;
628
+ return { ...folded, selection: undefined };
641
629
  }
642
- const accessControlledInclude = await buildIncludeWithAccessControl(listConfig.fields, { session: context.session, context }, config, 0,
643
- // Seed the cycle guard with the root list so a relationship cycle back
644
- // to it (self-referential or longer) stops re-descending.
645
- [listName]);
646
- const include = mergeIncludeWithAccessControl(folded.include, accessControlledInclude, listConfig.fields, config, listName);
647
- return { include, declaredOnly: folded.declaredOnly };
630
+ const include = await buildAccessScopedInclude(folded.include, listConfig.fields, { session: context.session, context }, config, listName);
631
+ return { include, declaredOnly: folded.declaredOnly, selection: undefined };
648
632
  }
649
- /**
650
- * Create findUnique operation with access control
651
- */
652
633
  function createFindUnique(listName,
653
634
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
654
635
  listConfig, prisma, context, config) {
655
636
  return async (args) => {
656
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
657
637
  warnIfSelectIgnored(args, listName, 'findUnique');
658
- // Enforce unique-`where` (Keystone `findOne` parity). This is a caller-shape
659
- // check independent of access, so it runs first and THROWS on misuse — it is
660
- // not an access denial and must not be masked as a silent `null`. The
661
- // type-level constraint already lives on the generated delegate: the custom
662
- // `<List>FindUniqueArgs` only Omits `select`/`include` from
663
- // `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
664
- // `<List>WhereUniqueInput` — this runtime guard backstops untyped callers.
638
+ // Runs first, before the access check below a non-unique `where` is a
639
+ // caller-shape error (see `assertUniqueWhere`), not an access denial. The
640
+ // generated `<List>FindUniqueArgs` only Omits `select`/`include` from
641
+ // Prisma's own type, so `where` stays `<List>WhereUniqueInput` this
642
+ // runtime guard backstops untyped callers.
665
643
  assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName);
666
- // Check query access (skip if sudo mode)
667
644
  let where = args.where;
668
645
  if (!context._isSudo) {
669
646
  const queryAccess = listConfig.access?.operation?.query;
@@ -674,28 +651,27 @@ listConfig, prisma, context, config) {
674
651
  if (accessResult === false) {
675
652
  return null;
676
653
  }
677
- // Merge access filter with where clause
678
654
  const mergedWhere = mergeFilters(args.where, accessResult);
679
655
  if (mergedWhere === null) {
680
656
  return null;
681
657
  }
682
658
  where = mergedWhere;
683
659
  }
684
- // When a query fragment is provided, build the include from the fragment
685
- // instead of the access-controlled include. Access control still runs via
686
- // filterReadableFields; the fragment then narrows to only the requested fields.
660
+ // Access control still runs via filterReadableFields even though a
661
+ // fragment drives `include`; the fragment only narrows which fields come back.
687
662
  const fragment = isFragment(args.query) ? args.query : null;
688
663
  // Resolve `include`, folding any declared dependencies (`needs`,
689
664
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
690
665
  // already produces — see `resolveReadInclude`'s doc comment.
691
- let { include, declaredOnly } = await resolveReadInclude(args.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
666
+ let { include, declaredOnly, selection } = await resolveReadInclude(args.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
692
667
  // Virtual fields have no database column. Whichever path produced
693
668
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
694
669
  // virtual key must never reach Prisma — it would throw "Unknown field"
695
- // (#628). The virtual value is still computed unconditionally below by
696
- // `filterReadableFields`, independent of what was requested here.
670
+ // (#628). Below, `filterReadableFields` computes a virtual field's value
671
+ // exactly when `selection` says the read is going to return it (ADR-0027)
672
+ // — every one of them for a bare/`include`-based read (`selection` is
673
+ // `undefined`), only the ones a fragment named otherwise.
697
674
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
698
- // Execute query with optimized includes
699
675
  // Access Prisma model dynamically - required because model names are generated at runtime
700
676
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
701
677
  const model = prisma[getDbKey(listName)];
@@ -706,33 +682,29 @@ listConfig, prisma, context, config) {
706
682
  if (!item) {
707
683
  return null;
708
684
  }
709
- // Filter readable fields and apply resolveOutput hooks (including nested relationships)
710
685
  // Pass sudo flag through context to skip field-level access checks
711
686
  const filtered = await filterReadableFields(item, listConfig.fields, {
712
687
  session: context.session,
713
688
  context: { ...context, _isSudo: context._isSudo },
714
- }, config, 0, listName, declaredOnly);
715
- // When a fragment is provided, pick only the requested fields from the result
689
+ }, config, 0, listName, declaredOnly, selection);
716
690
  if (fragment) {
717
691
  return pickFields(filtered, fragment._fields);
718
692
  }
719
693
  return filtered;
720
694
  };
721
695
  }
722
- /**
723
- * Create findMany operation with access control
724
- */
725
696
  function createFindMany(listName,
726
697
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
727
698
  listConfig, prisma, context, config) {
728
699
  return async (args) => {
729
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
730
700
  warnIfSelectIgnored(args, listName, 'findMany');
731
- // Check singleton constraint (throw error instead of silently returning empty)
701
+ // Singleton misuse throws rather than silently returning `[]` — unlike an
702
+ // access denial, this is a caller-shape error.
732
703
  if (isSingletonList(listConfig)) {
733
704
  throw new ValidationError([`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`], {});
734
705
  }
735
- // Check query access (skip if sudo mode)
706
+ // Check query access first (skip if sudo mode) — this MUST run before the
707
+ // #912/#915 where/orderBy validation below. See the comment there for why.
736
708
  let where = args?.where;
737
709
  if (!context._isSudo) {
738
710
  const queryAccess = listConfig.access?.operation?.query;
@@ -743,26 +715,61 @@ listConfig, prisma, context, config) {
743
715
  if (accessResult === false) {
744
716
  return [];
745
717
  }
746
- // Merge access filter with where clause
747
- const mergedWhere = mergeFilters(args?.where, accessResult);
718
+ // #912 reject a `where`/`orderBy` key the list config doesn't declare
719
+ // (e.g. a Prisma-generated back-relation), and #915 — reject one naming
720
+ // a field this session cannot READ (closing a probe via a `count()`
721
+ // that varies with the withheld value, or an `orderBy` that leaks
722
+ // relative ordering). Both run only now that the caller is known to
723
+ // have SOME access to the list (`accessResult !== false`): the thrown
724
+ // errors name the offending key, and running them before the access
725
+ // check above would let a caller with ZERO access to the list learn a
726
+ // field's name and read-gating status from the error message alone —
727
+ // turning the validation itself into the kind of oracle #915 closes.
728
+ // `sudo` bypasses this whole branch, matching the write path.
729
+ validateQueryKeys({
730
+ where: args?.where,
731
+ orderBy: args?.orderBy,
732
+ listConfig,
733
+ listName,
734
+ config,
735
+ isSudo: false,
736
+ });
737
+ await validateQueryFieldReadAccess({
738
+ where: args?.where,
739
+ orderBy: args?.orderBy,
740
+ listConfig,
741
+ listName,
742
+ session: context.session,
743
+ context,
744
+ isSudo: false,
745
+ });
746
+ // #916 — scope every relation filter nested in `where`
747
+ // (`some`/`every`/`none`/`is`/`isNot`) by the RELATED list's own `query`
748
+ // access, recursing through every hop of a chain — the `where`
749
+ // counterpart to how `include` is already scoped below via
750
+ // `buildAccessScopedInclude`. Runs after the checks above for the same
751
+ // ordering reason: only once the caller is known to have SOME access to
752
+ // THIS list.
753
+ const scopedWhere = args?.where
754
+ ? (await buildAccessScopedWhere(args.where, listConfig, listName, config, {
755
+ session: context.session,
756
+ context,
757
+ }))
758
+ : args?.where;
759
+ const mergedWhere = mergeFilters(scopedWhere, accessResult);
748
760
  if (mergedWhere === null) {
749
761
  return [];
750
762
  }
751
763
  where = mergedWhere;
752
764
  }
753
- // When a query fragment is provided, build include from fragment fields
754
765
  const fragment = isFragment(args?.query) ? args.query : null;
755
766
  // Resolve `include`, folding any declared dependencies (`needs`,
756
767
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
757
768
  // already produces — see `resolveReadInclude`'s doc comment.
758
- let { include, declaredOnly } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
759
- // Virtual fields have no database column. Whichever path produced
760
- // `include` (fragment, access-controlled merge, or sudo passthrough), a
761
- // virtual key must never reach Prisma — it would throw "Unknown field"
762
- // (#628). The virtual value is still computed unconditionally below by
763
- // `filterReadableFields`, independent of what was requested here.
769
+ let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
770
+ // Strips virtual keys from `include` before the Prisma call — see the
771
+ // `createFindUnique` comment above for why (#628, ADR-0027).
764
772
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
765
- // Execute query with optimized includes
766
773
  // Access Prisma model dynamically - required because model names are generated at runtime
767
774
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
768
775
  const model = prisma[getDbKey(listName)];
@@ -773,13 +780,11 @@ listConfig, prisma, context, config) {
773
780
  skip: args?.skip,
774
781
  include,
775
782
  });
776
- // Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
777
783
  // Pass sudo flag through context to skip field-level access checks
778
784
  const filtered = await Promise.all(items.map((item) => filterReadableFields(item, listConfig.fields, {
779
785
  session: context.session,
780
786
  context: { ...context, _isSudo: context._isSudo },
781
- }, config, 0, listName, declaredOnly)));
782
- // When a fragment is provided, pick only the requested fields from each result
787
+ }, config, 0, listName, declaredOnly, selection)));
783
788
  if (fragment) {
784
789
  return filtered.map((item) => pickFields(item, fragment._fields));
785
790
  }
@@ -801,9 +806,6 @@ function createFindFirst(findManyOp) {
801
806
  return result[0] ?? null;
802
807
  };
803
808
  }
804
- /**
805
- * Create create operation with access control and hooks
806
- */
807
809
  function createCreate(listName,
808
810
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
809
811
  listConfig, prisma, context, config) {
@@ -821,10 +823,8 @@ listConfig, prisma, context, config) {
821
823
  });
822
824
  };
823
825
  }
824
- /**
825
- * Create createMany operation with access control and hooks
826
- * Runs create in a loop to ensure all hooks and access control are executed for each item
827
- */
826
+ // Runs create in a loop (not Prisma's native createMany) so every item still
827
+ // gets its own hooks and access control.
828
828
  function createCreateMany(listName,
829
829
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
830
830
  listConfig, prisma, context, config,
@@ -839,9 +839,6 @@ createFn) {
839
839
  return results;
840
840
  };
841
841
  }
842
- /**
843
- * Create update operation with access control and hooks
844
- */
845
842
  function createUpdate(listName,
846
843
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
847
844
  listConfig, prisma, context, config) {
@@ -859,10 +856,8 @@ listConfig, prisma, context, config) {
859
856
  });
860
857
  };
861
858
  }
862
- /**
863
- * Create updateMany operation with access control and hooks
864
- * Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
865
- */
859
+ // Finds matching records, then updates each individually (not Prisma's native
860
+ // updateMany) so every item still gets its own hooks and access control.
866
861
  function createUpdateMany(listName,
867
862
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
868
863
  listConfig, prisma, context, config,
@@ -871,9 +866,7 @@ findManyFn,
871
866
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
872
867
  updateFn) {
873
868
  return async (args) => {
874
- // First, find all matching records (respects access control)
875
869
  const items = await findManyFn({ where: args.where });
876
- // Then update each one individually (runs hooks and access control for each)
877
870
  const results = [];
878
871
  for (const item of items) {
879
872
  const result = await updateFn({ where: { id: item.id }, data: args.data });
@@ -882,9 +875,6 @@ updateFn) {
882
875
  return results;
883
876
  };
884
877
  }
885
- /**
886
- * Create delete operation with access control and hooks
887
- */
888
878
  function createDelete(listName,
889
879
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
890
880
  listConfig, prisma, context, config) {
@@ -902,14 +892,12 @@ listConfig, prisma, context, config) {
902
892
  });
903
893
  };
904
894
  }
905
- /**
906
- * Create count operation with access control
907
- */
908
895
  function createCount(listName,
909
896
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
910
- listConfig, prisma, context) {
897
+ listConfig, prisma, context, config) {
911
898
  return async (args) => {
912
- // Check query access (skip if sudo mode)
899
+ // Check query access first (skip if sudo mode) — this MUST run before the
900
+ // #912/#915 where validation below. See the comment there for why.
913
901
  let where = args?.where;
914
902
  if (!context._isSudo) {
915
903
  const queryAccess = listConfig.access?.operation?.query;
@@ -920,14 +908,47 @@ listConfig, prisma, context) {
920
908
  if (accessResult === false) {
921
909
  return 0;
922
910
  }
923
- // Merge access filter with where clause
924
- const mergedWhere = mergeFilters(args?.where, accessResult);
911
+ // #912 reject a `where` key the list config doesn't declare (e.g. a
912
+ // Prisma-generated back-relation), and #915 — reject one naming a field
913
+ // this session cannot READ. `count` leaks the most cleanly of any read
914
+ // op — a bare count answers a predicate with no rows returned at all —
915
+ // so it gets the same reject, not a lesser one. Both run only now that
916
+ // the caller is known to have SOME access to the list (`accessResult
917
+ // !== false`) — see the identical comment in `createFindMany` for why
918
+ // that ordering matters: running them before the access check would
919
+ // let a fully-denied caller learn a field's name and read-gating
920
+ // status from the thrown error alone. `sudo` bypasses this whole
921
+ // branch, matching the write path.
922
+ validateQueryKeys({
923
+ where: args?.where,
924
+ listConfig,
925
+ listName,
926
+ config,
927
+ isSudo: false,
928
+ });
929
+ await validateQueryFieldReadAccess({
930
+ where: args?.where,
931
+ listConfig,
932
+ listName,
933
+ session: context.session,
934
+ context,
935
+ isSudo: false,
936
+ });
937
+ // #916 — scope every relation filter nested in `where` by the RELATED
938
+ // list's own `query` access. See the identical comment in
939
+ // `createFindMany` for why this runs here, in this order.
940
+ const scopedWhere = args?.where
941
+ ? (await buildAccessScopedWhere(args.where, listConfig, listName, config, {
942
+ session: context.session,
943
+ context,
944
+ }))
945
+ : args?.where;
946
+ const mergedWhere = mergeFilters(scopedWhere, accessResult);
925
947
  if (mergedWhere === null) {
926
948
  return 0;
927
949
  }
928
950
  where = mergedWhere;
929
951
  }
930
- // Execute count
931
952
  // Access Prisma model dynamically - required because model names are generated at runtime
932
953
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
933
954
  const model = prisma[getDbKey(listName)];
@@ -937,23 +958,16 @@ listConfig, prisma, context) {
937
958
  return count;
938
959
  };
939
960
  }
940
- /**
941
- * Create get operation for singleton lists
942
- * Returns the single record, or auto-creates it if enabled
943
- */
944
961
  function createGet(listName,
945
962
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
946
963
  listConfig, prisma, context, config,
947
964
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
948
965
  createFn) {
949
966
  return async (args) => {
950
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
951
967
  warnIfSelectIgnored(args, listName, 'get');
952
- // First try to find the existing record
953
968
  // Access Prisma model dynamically - required because model names are generated at runtime
954
969
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
955
970
  const model = prisma[getDbKey(listName)];
956
- // Check query access (skip if sudo mode)
957
971
  let where = {};
958
972
  if (!context._isSudo) {
959
973
  const queryAccess = listConfig.access?.operation?.query;
@@ -964,45 +978,39 @@ createFn) {
964
978
  if (accessResult === false) {
965
979
  return null;
966
980
  }
967
- // Merge access filter (for singleton, we don't have a specific where clause)
981
+ // A singleton has no per-record `where`, so the access filter (if any) is
982
+ // the whole `where`.
968
983
  if (accessResult && typeof accessResult === 'object') {
969
984
  where = accessResult;
970
985
  }
971
986
  }
972
- // When a query fragment is provided, build the include from the fragment
973
- // instead of the access-controlled include. Access control still runs via
974
- // filterReadableFields; the fragment then narrows to only the requested fields.
987
+ // Access control still runs via filterReadableFields even though a
988
+ // fragment drives `include`; the fragment only narrows which fields come back.
975
989
  const fragment = isFragment(args?.query) ? args.query : null;
976
990
  // Resolve `include`, folding any declared dependencies (`needs`,
977
991
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
978
992
  // already produces — see `resolveReadInclude`'s doc comment.
979
- let { include, declaredOnly } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
993
+ let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
980
994
  // Virtual fields have no database column and must never reach Prisma (#628).
981
995
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
982
- // Try to find the record
983
996
  const item = await model.findFirst({
984
997
  where,
985
998
  include,
986
999
  });
987
- // If record exists, return it
988
1000
  if (item) {
989
- // Filter readable fields and apply resolveOutput hooks
990
1001
  const filtered = await filterReadableFields(item, listConfig.fields, {
991
1002
  session: context.session,
992
1003
  context: { ...context, _isSudo: context._isSudo },
993
- }, config, 0, listName, declaredOnly);
994
- // When a fragment is provided, pick only the requested fields from the result
1004
+ }, config, 0, listName, declaredOnly, selection);
995
1005
  if (fragment) {
996
1006
  return pickFields(filtered, fragment._fields);
997
1007
  }
998
1008
  return filtered;
999
1009
  }
1000
- // If no record and auto-create is enabled, create it
1001
1010
  if (shouldAutoCreate(listConfig)) {
1002
1011
  const defaultData = getDefaultData(listConfig);
1003
1012
  return await createFn({ data: defaultData });
1004
1013
  }
1005
- // No record and auto-create is disabled
1006
1014
  return null;
1007
1015
  };
1008
1016
  }