@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
@@ -1,23 +1,15 @@
1
- import { checkAccess, mergeFilters, filterReadableFields, buildAccessScopedInclude, 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
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.
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.
44
36
  *
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.
51
- *
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
  }
@@ -653,24 +630,17 @@ listConfig, context, config) {
653
630
  const include = await buildAccessScopedInclude(folded.include, listConfig.fields, { session: context.session, context }, config, listName);
654
631
  return { include, declaredOnly: folded.declaredOnly, selection: undefined };
655
632
  }
656
- /**
657
- * Create findUnique operation with access control
658
- */
659
633
  function createFindUnique(listName,
660
634
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
661
635
  listConfig, prisma, context, config) {
662
636
  return async (args) => {
663
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
664
637
  warnIfSelectIgnored(args, listName, 'findUnique');
665
- // Enforce unique-`where` (Keystone `findOne` parity). This is a caller-shape
666
- // check independent of access, so it runs first and THROWS on misuse — it is
667
- // not an access denial and must not be masked as a silent `null`. The
668
- // type-level constraint already lives on the generated delegate: the custom
669
- // `<List>FindUniqueArgs` only Omits `select`/`include` from
670
- // `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
671
- // `<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.
672
643
  assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName);
673
- // Check query access (skip if sudo mode)
674
644
  let where = args.where;
675
645
  if (!context._isSudo) {
676
646
  const queryAccess = listConfig.access?.operation?.query;
@@ -681,16 +651,14 @@ listConfig, prisma, context, config) {
681
651
  if (accessResult === false) {
682
652
  return null;
683
653
  }
684
- // Merge access filter with where clause
685
654
  const mergedWhere = mergeFilters(args.where, accessResult);
686
655
  if (mergedWhere === null) {
687
656
  return null;
688
657
  }
689
658
  where = mergedWhere;
690
659
  }
691
- // When a query fragment is provided, build the include from the fragment
692
- // instead of the access-controlled include. Access control still runs via
693
- // 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.
694
662
  const fragment = isFragment(args.query) ? args.query : null;
695
663
  // Resolve `include`, folding any declared dependencies (`needs`,
696
664
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
@@ -704,7 +672,6 @@ listConfig, prisma, context, config) {
704
672
  // — every one of them for a bare/`include`-based read (`selection` is
705
673
  // `undefined`), only the ones a fragment named otherwise.
706
674
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
707
- // Execute query with optimized includes
708
675
  // Access Prisma model dynamically - required because model names are generated at runtime
709
676
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
710
677
  const model = prisma[getDbKey(listName)];
@@ -715,33 +682,29 @@ listConfig, prisma, context, config) {
715
682
  if (!item) {
716
683
  return null;
717
684
  }
718
- // Filter readable fields and apply resolveOutput hooks (including nested relationships)
719
685
  // Pass sudo flag through context to skip field-level access checks
720
686
  const filtered = await filterReadableFields(item, listConfig.fields, {
721
687
  session: context.session,
722
688
  context: { ...context, _isSudo: context._isSudo },
723
689
  }, config, 0, listName, declaredOnly, selection);
724
- // When a fragment is provided, pick only the requested fields from the result
725
690
  if (fragment) {
726
691
  return pickFields(filtered, fragment._fields);
727
692
  }
728
693
  return filtered;
729
694
  };
730
695
  }
731
- /**
732
- * Create findMany operation with access control
733
- */
734
696
  function createFindMany(listName,
735
697
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
736
698
  listConfig, prisma, context, config) {
737
699
  return async (args) => {
738
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
739
700
  warnIfSelectIgnored(args, listName, 'findMany');
740
- // 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.
741
703
  if (isSingletonList(listConfig)) {
742
704
  throw new ValidationError([`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`], {});
743
705
  }
744
- // 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.
745
708
  let where = args?.where;
746
709
  if (!context._isSudo) {
747
710
  const queryAccess = listConfig.access?.operation?.query;
@@ -752,28 +715,61 @@ listConfig, prisma, context, config) {
752
715
  if (accessResult === false) {
753
716
  return [];
754
717
  }
755
- // Merge access filter with where clause
756
- 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);
757
760
  if (mergedWhere === null) {
758
761
  return [];
759
762
  }
760
763
  where = mergedWhere;
761
764
  }
762
- // When a query fragment is provided, build include from fragment fields
763
765
  const fragment = isFragment(args?.query) ? args.query : null;
764
766
  // Resolve `include`, folding any declared dependencies (`needs`,
765
767
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
766
768
  // already produces — see `resolveReadInclude`'s doc comment.
767
769
  let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
768
- // Virtual fields have no database column. Whichever path produced
769
- // `include` (fragment, access-controlled merge, or sudo passthrough), a
770
- // virtual key must never reach Prisma — it would throw "Unknown field"
771
- // (#628). Below, `filterReadableFields` computes a virtual field's value
772
- // exactly when `selection` says the read is going to return it (ADR-0027)
773
- // — every one of them for a bare/`include`-based read (`selection` is
774
- // `undefined`), only the ones a fragment named otherwise.
770
+ // Strips virtual keys from `include` before the Prisma call — see the
771
+ // `createFindUnique` comment above for why (#628, ADR-0027).
775
772
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
776
- // Execute query with optimized includes
777
773
  // Access Prisma model dynamically - required because model names are generated at runtime
778
774
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
779
775
  const model = prisma[getDbKey(listName)];
@@ -784,13 +780,11 @@ listConfig, prisma, context, config) {
784
780
  skip: args?.skip,
785
781
  include,
786
782
  });
787
- // Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
788
783
  // Pass sudo flag through context to skip field-level access checks
789
784
  const filtered = await Promise.all(items.map((item) => filterReadableFields(item, listConfig.fields, {
790
785
  session: context.session,
791
786
  context: { ...context, _isSudo: context._isSudo },
792
787
  }, config, 0, listName, declaredOnly, selection)));
793
- // When a fragment is provided, pick only the requested fields from each result
794
788
  if (fragment) {
795
789
  return filtered.map((item) => pickFields(item, fragment._fields));
796
790
  }
@@ -812,9 +806,6 @@ function createFindFirst(findManyOp) {
812
806
  return result[0] ?? null;
813
807
  };
814
808
  }
815
- /**
816
- * Create create operation with access control and hooks
817
- */
818
809
  function createCreate(listName,
819
810
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
820
811
  listConfig, prisma, context, config) {
@@ -832,10 +823,8 @@ listConfig, prisma, context, config) {
832
823
  });
833
824
  };
834
825
  }
835
- /**
836
- * Create createMany operation with access control and hooks
837
- * Runs create in a loop to ensure all hooks and access control are executed for each item
838
- */
826
+ // Runs create in a loop (not Prisma's native createMany) so every item still
827
+ // gets its own hooks and access control.
839
828
  function createCreateMany(listName,
840
829
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
841
830
  listConfig, prisma, context, config,
@@ -850,9 +839,6 @@ createFn) {
850
839
  return results;
851
840
  };
852
841
  }
853
- /**
854
- * Create update operation with access control and hooks
855
- */
856
842
  function createUpdate(listName,
857
843
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
858
844
  listConfig, prisma, context, config) {
@@ -870,10 +856,8 @@ listConfig, prisma, context, config) {
870
856
  });
871
857
  };
872
858
  }
873
- /**
874
- * Create updateMany operation with access control and hooks
875
- * Runs findMany to get records, then update in a loop to ensure all hooks and access control are executed
876
- */
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.
877
861
  function createUpdateMany(listName,
878
862
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
879
863
  listConfig, prisma, context, config,
@@ -882,9 +866,7 @@ findManyFn,
882
866
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
883
867
  updateFn) {
884
868
  return async (args) => {
885
- // First, find all matching records (respects access control)
886
869
  const items = await findManyFn({ where: args.where });
887
- // Then update each one individually (runs hooks and access control for each)
888
870
  const results = [];
889
871
  for (const item of items) {
890
872
  const result = await updateFn({ where: { id: item.id }, data: args.data });
@@ -893,9 +875,6 @@ updateFn) {
893
875
  return results;
894
876
  };
895
877
  }
896
- /**
897
- * Create delete operation with access control and hooks
898
- */
899
878
  function createDelete(listName,
900
879
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
901
880
  listConfig, prisma, context, config) {
@@ -913,14 +892,12 @@ listConfig, prisma, context, config) {
913
892
  });
914
893
  };
915
894
  }
916
- /**
917
- * Create count operation with access control
918
- */
919
895
  function createCount(listName,
920
896
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
921
- listConfig, prisma, context) {
897
+ listConfig, prisma, context, config) {
922
898
  return async (args) => {
923
- // 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.
924
901
  let where = args?.where;
925
902
  if (!context._isSudo) {
926
903
  const queryAccess = listConfig.access?.operation?.query;
@@ -931,14 +908,47 @@ listConfig, prisma, context) {
931
908
  if (accessResult === false) {
932
909
  return 0;
933
910
  }
934
- // Merge access filter with where clause
935
- 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);
936
947
  if (mergedWhere === null) {
937
948
  return 0;
938
949
  }
939
950
  where = mergedWhere;
940
951
  }
941
- // Execute count
942
952
  // Access Prisma model dynamically - required because model names are generated at runtime
943
953
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
944
954
  const model = prisma[getDbKey(listName)];
@@ -948,23 +958,16 @@ listConfig, prisma, context) {
948
958
  return count;
949
959
  };
950
960
  }
951
- /**
952
- * Create get operation for singleton lists
953
- * Returns the single record, or auto-creates it if enabled
954
- */
955
961
  function createGet(listName,
956
962
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
957
963
  listConfig, prisma, context, config,
958
964
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
959
965
  createFn) {
960
966
  return async (args) => {
961
- // `select` is a visible no-op: warn, then proceed with include/query narrowing.
962
967
  warnIfSelectIgnored(args, listName, 'get');
963
- // First try to find the existing record
964
968
  // Access Prisma model dynamically - required because model names are generated at runtime
965
969
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
966
970
  const model = prisma[getDbKey(listName)];
967
- // Check query access (skip if sudo mode)
968
971
  let where = {};
969
972
  if (!context._isSudo) {
970
973
  const queryAccess = listConfig.access?.operation?.query;
@@ -975,14 +978,14 @@ createFn) {
975
978
  if (accessResult === false) {
976
979
  return null;
977
980
  }
978
- // 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`.
979
983
  if (accessResult && typeof accessResult === 'object') {
980
984
  where = accessResult;
981
985
  }
982
986
  }
983
- // When a query fragment is provided, build the include from the fragment
984
- // instead of the access-controlled include. Access control still runs via
985
- // 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.
986
989
  const fragment = isFragment(args?.query) ? args.query : null;
987
990
  // Resolve `include`, folding any declared dependencies (`needs`,
988
991
  // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
@@ -990,30 +993,24 @@ createFn) {
990
993
  let { include, declaredOnly, selection } = await resolveReadInclude(args?.include, fragment ? fragment._fields : undefined, listName, listConfig, context, config);
991
994
  // Virtual fields have no database column and must never reach Prisma (#628).
992
995
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config);
993
- // Try to find the record
994
996
  const item = await model.findFirst({
995
997
  where,
996
998
  include,
997
999
  });
998
- // If record exists, return it
999
1000
  if (item) {
1000
- // Filter readable fields and apply resolveOutput hooks
1001
1001
  const filtered = await filterReadableFields(item, listConfig.fields, {
1002
1002
  session: context.session,
1003
1003
  context: { ...context, _isSudo: context._isSudo },
1004
1004
  }, config, 0, listName, declaredOnly, selection);
1005
- // When a fragment is provided, pick only the requested fields from the result
1006
1005
  if (fragment) {
1007
1006
  return pickFields(filtered, fragment._fields);
1008
1007
  }
1009
1008
  return filtered;
1010
1009
  }
1011
- // If no record and auto-create is enabled, create it
1012
1010
  if (shouldAutoCreate(listConfig)) {
1013
1011
  const defaultData = getDefaultData(listConfig);
1014
1012
  return await createFn({ data: defaultData });
1015
1013
  }
1016
- // No record and auto-create is disabled
1017
1014
  return null;
1018
1015
  };
1019
1016
  }