@opensaas/stack-core 0.38.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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +123 -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
@@ -19,34 +19,15 @@ import { getDbKey } from '../lib/case-utils.js'
19
19
  import { applyCreateDefaults } from './apply-defaults.js'
20
20
 
21
21
  /**
22
- * Nested writes (#569 / ADR-0010).
23
- *
24
- * Nested `create`/`update`/`delete` must fire the SAME list- and field-level
25
- * `beforeOperation`/`afterOperation` as the equivalent top-level write, so a
26
- * record's side effects are identical whether it was written nested or
27
- * top-level. Persistence itself is still performed by Prisma's single nested
28
- * write (so Prisma keeps owning FK ordering and intra-statement atomicity); we
29
- * run the nested records' `beforeOperation` BEFORE that persist and their
30
- * `afterOperation` AFTER it, all inside the one interactive transaction the
31
- * Write Pipeline opens.
32
- *
33
- * Mechanism (per ADR-0010, "hooks around a single nested persist"):
34
- * - `processNestedOperations` runs nested resolveInput/validate/field-rules
35
- * (as before) AND nested `beforeOperation`, and returns the transformed
36
- * payload together with a list of deferred {@link AfterTask}s.
37
- * - The Write Pipeline persists the parent (with the nested relations
38
- * `include`d so the persisted nested rows come back), then calls
39
- * {@link runAfterTasks} so each nested record's `afterOperation` fires with
40
- * a real persisted `item` and (for update/delete) its `originalItem`.
41
- * - Everything runs inside the transaction, so a throwing `beforeOperation`/
42
- * `afterOperation` rolls back the whole write.
22
+ * Nested writes (#569 / ADR-0010): give nested `create`/`update`/`delete` the
23
+ * same before/afterOperation hooks as an equivalent top-level write, inside
24
+ * the one transaction the Write Pipeline opens. `processNestedOperations`
25
+ * transforms the payload and runs `beforeOperation`, returning deferred
26
+ * {@link AfterTask}s that {@link runAfterTasks} runs once the parent has
27
+ * persisted. See ADR-0010 for the full mechanism.
43
28
  */
44
29
 
45
- /**
46
- * A deferred nested `afterOperation` task, run after the parent has persisted.
47
- * It receives the persisted parent row (with nested relations included) so it
48
- * can recover the persisted nested `item`.
49
- */
30
+ /** A deferred nested `afterOperation` task, run once the parent has persisted. */
50
31
  export interface AfterTask {
51
32
  /** Field name on the parent linking to the related list (for include lookup). */
52
33
  fieldName: string
@@ -67,16 +48,11 @@ export interface NestedOpsResult {
67
48
  includeFields: Set<string>
68
49
  }
69
50
 
70
- /**
71
- * Check if a field config is a relationship field
72
- */
73
51
  function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
74
52
  return fieldConfig?.type === 'relationship'
75
53
  }
76
54
 
77
- /**
78
- * Resolve the related list name for a related list config (config object identity).
79
- */
55
+ /** Resolve a list name by matching config object identity, not by name. */
80
56
  function findListName(
81
57
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
82
58
  relatedListConfig: ListConfig<any>,
@@ -94,8 +70,7 @@ function findListName(
94
70
  * Read the rows of a parent's included relation as an array.
95
71
  *
96
72
  * A to-one relation comes back as a single row (or `null`); a to-many relation
97
- * comes back as an array. This normalises both to an array so callers can apply
98
- * a uniform id-diff.
73
+ * comes back as an array. Normalises both so callers can apply a uniform id-diff.
99
74
  */
100
75
  function includedRows(
101
76
  parentResult: Record<string, unknown>,
@@ -123,13 +98,10 @@ function recoverUpdatedRow(
123
98
  }
124
99
 
125
100
  /**
126
- * Recover the CREATED nested rows from the parent result by id-diff.
127
- *
128
- * Created rows have no known id before the write, so they are identified as the
129
- * included rows whose ids are NOT in `preExistingIds` (the set of related-row
130
- * ids captured before the persist). Returned in include order, which the create
131
- * handler pairs to its create-payload entries by position (see
132
- * {@link CreatedRowRecovery}).
101
+ * Recover the CREATED nested rows from the parent result by id-diff: the
102
+ * included rows whose ids are NOT in `preExistingIds`. Returned in include
103
+ * order, which {@link CreatedRowRecovery} pairs to create-payload entries by
104
+ * position reordering this would break that pairing.
133
105
  */
134
106
  function recoverCreatedRows(
135
107
  parentResult: Record<string, unknown>,
@@ -143,19 +115,13 @@ function recoverCreatedRows(
143
115
 
144
116
  /**
145
117
  * Shared, memoised recovery of the rows created for ONE nested `create` payload
146
- * on ONE relation field.
118
+ * on ONE relation field, by id-diff against the parent's pre-persist related
119
+ * ids, paired to create-payload entries by position. See ADR-0010. The id-diff
120
+ * is cached per parent result so every entry's task shares it.
147
121
  *
148
- * A to-many `create: [{A},{B}]` produces several rows that must each fire their
149
- * own `afterOperation` against their OWN row. We cannot tell which included row
150
- * corresponds to which payload entry by content alone, so we identify the set of
151
- * NEW rows by id-diff against the ids that existed before the persist, then pair
152
- * them to the create-payload entries by POSITION (Prisma preserves create-array
153
- * order in the included result). The id-diff is computed once per parent result
154
- * and cached so every entry's task shares it.
155
- *
156
- * `inputData`↔row pairing is therefore positional and best-effort; `item`
157
- * correctness (each task gets a genuinely-created, distinct row) is guaranteed:
158
- * a pre-existing row can never be returned because it is excluded by the diff.
122
+ * `inputData`↔row pairing is positional and best-effort; `item` correctness
123
+ * (each task gets a genuinely-created, distinct row) is guaranteed a
124
+ * pre-existing row can never be returned because the diff excludes it.
159
125
  */
160
126
  interface CreatedRowRecovery {
161
127
  /** Recover the created row for the create-payload entry at `index`. */
@@ -182,14 +148,11 @@ function createCreatedRowRecovery(
182
148
 
183
149
  /**
184
150
  * Capture the ids of the rows currently linked to the parent via `fieldName`,
185
- * BEFORE the parent persists. Used to identify which included rows are NEW
186
- * (created by this write) afterwards.
151
+ * BEFORE the parent persists, so created rows can later be identified by id-diff.
152
+ * Empty for a parent CREATE (no parent row exists yet to have related rows).
187
153
  *
188
- * - For a parent CREATE there are no pre-existing related rows (the parent does
189
- * not exist yet), so the set is empty.
190
- * - For a parent UPDATE we read the parent row's current relation and collect
191
- * its ids. The same `tx` client is used so the read participates in the
192
- * transaction and sees a consistent snapshot.
154
+ * `prisma` must be the transaction client the read has to participate in the
155
+ * transaction to see a consistent snapshot.
193
156
  */
194
157
  async function capturePreExistingIds(
195
158
  parentListName: string,
@@ -220,11 +183,9 @@ async function capturePreExistingIds(
220
183
  }
221
184
 
222
185
  /**
223
- * Process nested create operations.
224
- *
225
- * Runs the target list's full input pipeline (resolveInput → validate →
226
- * field-rules → filter-writable → recurse) AND its `beforeOperation`, then
227
- * registers an `afterOperation` task keyed to the parent's included relation.
186
+ * Process nested create operations: run the target list's full input pipeline
187
+ * and `beforeOperation`, then register an `afterOperation` task keyed to the
188
+ * parent's included relation.
228
189
  */
229
190
  async function processNestedCreate(
230
191
  items: Record<string, unknown> | Array<Record<string, unknown>>,
@@ -242,7 +203,6 @@ async function processNestedCreate(
242
203
 
243
204
  const processedItems = await Promise.all(
244
205
  itemsArray.map(async (item, index) => {
245
- // 1. Check create access (skip if sudo mode)
246
206
  if (!context._isSudo) {
247
207
  const createAccess = relatedListConfig.access?.operation?.create
248
208
  const accessResult = await checkAccess(createAccess, {
@@ -255,7 +215,6 @@ async function processNestedCreate(
255
215
  }
256
216
  }
257
217
 
258
- // 2. Execute list-level resolveInput hook
259
218
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
260
219
  listKey: relatedListName,
261
220
  operation: 'create',
@@ -265,7 +224,6 @@ async function processNestedCreate(
265
224
  context,
266
225
  })
267
226
 
268
- // 3. Execute field-level resolveInput hooks
269
227
  resolvedData = await executeFieldResolveInputHooks(
270
228
  item,
271
229
  resolvedData,
@@ -275,13 +233,11 @@ async function processNestedCreate(
275
233
  relatedListName,
276
234
  )
277
235
 
278
- // 3.5 Apply field defaults to omitted inputs (resolve-then-validate, #615).
279
- // Mirrors the top-level Hook Pipeline so a nested required-with-default
280
- // field resolves to its default before validation instead of failing
281
- // `isRequired`. Create-only; explicit values (incl. null) are preserved.
236
+ // Apply field defaults to omitted inputs (resolve-then-validate, #615) so
237
+ // a nested required-with-default field resolves before `isRequired` runs,
238
+ // mirroring the top-level pipeline. Create-only.
282
239
  resolvedData = applyCreateDefaults(resolvedData, relatedListConfig.fields)
283
240
 
284
- // 4. Execute validate hook
285
241
  await executeValidate(relatedListConfig.hooks, {
286
242
  listKey: relatedListName,
287
243
  operation: 'create',
@@ -291,7 +247,6 @@ async function processNestedCreate(
291
247
  context,
292
248
  })
293
249
 
294
- // 4.5 Field-level validate hooks
295
250
  await executeFieldValidateHooks(
296
251
  item,
297
252
  resolvedData,
@@ -301,14 +256,13 @@ async function processNestedCreate(
301
256
  relatedListName,
302
257
  )
303
258
 
304
- // 5. Field validation (built-in rules)
305
259
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'create')
306
260
  if (validation.errors.length > 0) {
307
261
  throw new ValidationError(validation.errors, validation.fieldErrors)
308
262
  }
309
263
 
310
- // 5.5 Split multi-column fields into physical columns (#789). Only
311
- // reached once the logical value has passed validation above.
264
+ // Split multi-column fields into physical columns (#789) — must run after
265
+ // validation, which operates on the logical (pre-split) value.
312
266
  resolvedData = await splitMultiColumnFields(
313
267
  item,
314
268
  resolvedData,
@@ -317,7 +271,6 @@ async function processNestedCreate(
317
271
  context,
318
272
  )
319
273
 
320
- // 6. Filter writable fields
321
274
  const filtered = await filterWritableFields(
322
275
  resolvedData,
323
276
  relatedListConfig.fields,
@@ -329,9 +282,8 @@ async function processNestedCreate(
329
282
  },
330
283
  )
331
284
 
332
- // 7. Recursively process nested operations in this item. This nested row
333
- // is itself being CREATED, so its own relations have no pre-existing rows
334
- // (parent originalItem is undefined → empty pre-existing set).
285
+ // This nested row is itself being created, so it has no pre-existing
286
+ // related rows of its own (parent originalItem is undefined below).
335
287
  const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(
336
288
  filtered,
337
289
  relatedListConfig.fields,
@@ -340,12 +292,11 @@ async function processNestedCreate(
340
292
  'create',
341
293
  relatedListName,
342
294
  undefined,
343
- // This nested row is being CREATED, so its enclosing inputData is its own
344
- // create payload (passed to the connect-site owning-field gate, #588).
295
+ // Its own create payload is the enclosing inputData for the connect-site
296
+ // owning-field gate (#588).
345
297
  item,
346
298
  )
347
299
 
348
- // 8. Field-level beforeOperation (side effects) for this nested create
349
300
  await executeFieldBeforeOperationHooks(
350
301
  item,
351
302
  resolvedData,
@@ -355,7 +306,6 @@ async function processNestedCreate(
355
306
  relatedListName,
356
307
  )
357
308
 
358
- // 9. List-level beforeOperation for this nested create
359
309
  await executeBeforeOperation(relatedListConfig.hooks, {
360
310
  listKey: relatedListName,
361
311
  operation: 'create',
@@ -364,26 +314,19 @@ async function processNestedCreate(
364
314
  context,
365
315
  })
366
316
 
367
- // 10. Register afterOperation: fires once the parent (and thus this nested
368
- // row) has persisted. The created row is recovered by id-diff and paired
369
- // to THIS create-payload entry by position (see CreatedRowRecovery), so a
370
- // to-many `create: [{A},{B}]` fires once per row, each against its OWN
371
- // distinct row, and never against a pre-existing sibling.
317
+ // Fires once the parent has persisted. The row is recovered by id-diff and
318
+ // paired to THIS entry by position (see CreatedRowRecovery), so a to-many
319
+ // `create: [{A},{B}]` fires once per row against its own distinct row.
372
320
  afterTasks.push({
373
321
  fieldName,
374
322
  run: async (parentResult) => {
375
323
  const createdItem = recovery.rowAt(parentResult, index)
376
324
  if (!createdItem) {
377
- // The created row could not be identified by id-diff — the parent
378
- // write did not return this nested relation (e.g. the underlying
379
- // client does not echo `include`d relations). We must NOT hand an
380
- // id-less `{}` to a hook as if it were the persisted row (finding 4:
381
- // that would fire `afterOperation` against a fabricated item). The
382
- // before-persist hooks have already run; we deliberately SKIP this
383
- // record's create `afterOperation` rather than fire it with a bogus
384
- // item. Real Prisma always echoes the `include`d relation, so this
385
- // skip is reached only by clients/mocks that omit it. `item`
386
- // correctness is the must-have; a missing row is never fabricated.
325
+ // Could not identify the created row by id-diff — the parent write
326
+ // didn't echo this nested relation (e.g. a client/mock that doesn't
327
+ // honour `include`; real Prisma always does). Deliberately SKIP this
328
+ // record's `afterOperation` rather than fabricate an id-less item
329
+ // `item` correctness is the must-have, so a missing row is never faked.
387
330
  return
388
331
  }
389
332
 
@@ -406,7 +349,6 @@ async function processNestedCreate(
406
349
  relatedListName,
407
350
  )
408
351
 
409
- // Run any deeper nested afterOperation tasks, scoped to the persisted row.
410
352
  await runAfterTasks(childAfterTasks, createdItem)
411
353
  },
412
354
  })
@@ -421,23 +363,18 @@ async function processNestedCreate(
421
363
  /**
422
364
  * Verify that a single connection target is reachable for the caller.
423
365
  *
424
- * Connecting an existing row references it; it does not modify the row's own
425
- * data. Mirroring Keystone, this requires **read/query** access on the target
426
- * list (not `update`). When query access returns a filter object, the filter is
427
- * evaluated in the DATABASE (not in memory) via
428
- * `findFirst({ where: { AND: [connection, accessFilter] } })`. The connect is
429
- * allowed iff that query returns a row, which correctly handles arbitrary
430
- * nested-relation predicates and boolean combinators (`AND`/`OR`/`some`/
431
- * `none`/`not`). The existence check is folded into the reachability query so a
432
- * non-existent id is still denied.
366
+ * Connecting references an existing row rather than modifying it, so mirroring
367
+ * Keystone it requires **read/query** access on the target (#578), not update.
368
+ * A filter-result query access is evaluated in the DATABASE via
369
+ * `findFirst({ where: { AND: [connection, accessFilter] } })` rather than in
370
+ * memory, so it correctly handles arbitrary nested-relation predicates and
371
+ * boolean combinators; a non-existent id is folded into the same check.
433
372
  *
434
- * In ADDITION to the target read/reachability check (#578), the OWNING
435
- * relationship field's field-level access (its `create`/`update` access on the
436
- * list being written, e.g. `Post.author`) must permit the connect (#588). This
437
- * is the other half Keystone required: a connect needs read access on the
438
- * target AND write access on the owning relationship field. If the owning
439
- * field's field-level access denies, the connect is denied even when the target
440
- * row is readable/reachable.
373
+ * In ADDITION, the OWNING relationship field's field-level access (e.g.
374
+ * `Post.author`'s `create`/`update` access) must permit the connect (#588) —
375
+ * the other half Keystone requires: read access on the target AND write access
376
+ * on the owning field. A deny here denies the connect even when the target row
377
+ * is readable/reachable.
441
378
  *
442
379
  * Sudo bypasses the entire check (handled by the caller).
443
380
  */
@@ -457,18 +394,10 @@ async function verifyConnectReachable(
457
394
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
458
395
  const model = (prisma as any)[getDbKey(relatedListName)]
459
396
 
460
- // #588 gate the connect by the OWNING relationship field's field-level
461
- // access (evaluated for the enclosing write's operation). This runs in
462
- // addition to the target read/reachability check below; a deny here denies
463
- // the connect even if the target row is readable. `checkFieldAccess` returns
464
- // `true` under sudo, but the caller already skips this whole function for
465
- // sudo, so the gate never fires for trusted writes.
466
- //
467
- // `item`/`inputData` are the ENCLOSING write's `originalItem`/`inputData` —
468
- // the SAME values the canonical Phase-5 `filterWritableFields` call passes for
469
- // this field — so a field-access rule that depends on `item` or `inputData`
470
- // (e.g. `({ item }) => item.status === 'draft'`) evaluates identically here and
471
- // at Phase 5, and the two gates cannot diverge into a spurious connect denial.
397
+ // #588 owning-field gate (see docblock above). `item`/`inputData` are the
398
+ // ENCLOSING write's `originalItem`/`inputData` the same values the canonical
399
+ // Phase-5 `filterWritableFields` call passes for this field so an
400
+ // item-/inputData-dependent field-access rule can't diverge between the two gates.
472
401
  const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
473
402
  session: context.session,
474
403
  item: enclosingItem,
@@ -479,20 +408,17 @@ async function verifyConnectReachable(
479
408
  throw new Error('Access denied: Cannot connect to this item')
480
409
  }
481
410
 
482
- // Connecting references an existing row; it requires READ (query) access on
483
- // the target, not update access.
484
411
  const queryAccess = relatedListConfig.access?.operation?.query
485
412
  const accessResult = await checkAccess(queryAccess, {
486
413
  session: context.session,
487
414
  context,
488
415
  })
489
416
 
490
- // Explicit denial.
491
417
  if (accessResult === false) {
492
418
  throw new Error('Access denied: Cannot connect to this item')
493
419
  }
494
420
 
495
- // Full access: still verify the row exists (keep "Item not found" behaviour).
421
+ // Full access still verifies the row exists, to keep "Item not found" behaviour.
496
422
  if (accessResult === true) {
497
423
  const item = await model.findUnique({ where: connection })
498
424
  if (!item) {
@@ -501,11 +427,8 @@ async function verifyConnectReachable(
501
427
  return
502
428
  }
503
429
 
504
- // Filter result: confirm the row is reachable under the access filter by
505
- // AND-combining the connection identifier with the filter and querying the DB.
506
- // A non-existent id and an unreachable row both yield no row → denied. This
507
- // correctly evaluates arbitrary nested-relation predicates and boolean
508
- // combinators because the database does the matching, not an in-memory walk.
430
+ // Filter result: reachable iff AND-combining the connection with the filter
431
+ // returns a row (see docblock above).
509
432
  const reachable = await model.findFirst({
510
433
  where: { AND: [connection, accessResult] },
511
434
  })
@@ -534,7 +457,6 @@ async function processNestedConnect(
534
457
  ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
535
458
  const connectionsArray = Array.isArray(connections) ? connections : [connections]
536
459
 
537
- // Check read access for each item being connected (skip if sudo mode)
538
460
  if (!context._isSudo) {
539
461
  for (const connection of connectionsArray) {
540
462
  await verifyConnectReachable(
@@ -582,18 +504,15 @@ async function processNestedUpdate(
582
504
 
583
505
  const where = (update as Record<string, unknown>).where as Record<string, unknown>
584
506
 
585
- // Fetch the existing item — reused as `originalItem` for afterOperation.
507
+ // Fetched here and reused as `originalItem` for afterOperation.
586
508
  const originalItem = await model.findUnique({ where })
587
509
 
588
510
  if (!originalItem) {
589
511
  throw new Error('Cannot update: Item not found')
590
512
  }
591
513
 
592
- // The updated row's id is known up front, so the included-result read-back
593
- // finds this row directly by id.
594
514
  const knownId = typeof originalItem.id === 'string' ? (originalItem.id as string) : undefined
595
515
 
596
- // Check update access (skip if sudo mode)
597
516
  if (!context._isSudo) {
598
517
  const updateAccess = relatedListConfig.access?.operation?.update
599
518
  const accessResult = await checkAccess(updateAccess, {
@@ -607,7 +526,6 @@ async function processNestedUpdate(
607
526
  }
608
527
  }
609
528
 
610
- // Execute list-level resolveInput hook
611
529
  const updateData = (update as Record<string, unknown>).data as Record<string, unknown>
612
530
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
613
531
  listKey: relatedListName,
@@ -618,7 +536,6 @@ async function processNestedUpdate(
618
536
  context,
619
537
  })
620
538
 
621
- // Execute field-level resolveInput hooks
622
539
  resolvedData = await executeFieldResolveInputHooks(
623
540
  updateData,
624
541
  resolvedData,
@@ -629,7 +546,6 @@ async function processNestedUpdate(
629
546
  originalItem,
630
547
  )
631
548
 
632
- // Execute validate hook
633
549
  await executeValidate(relatedListConfig.hooks, {
634
550
  listKey: relatedListName,
635
551
  operation: 'update',
@@ -639,7 +555,6 @@ async function processNestedUpdate(
639
555
  context,
640
556
  })
641
557
 
642
- // Field-level validate hooks
643
558
  await executeFieldValidateHooks(
644
559
  updateData,
645
560
  resolvedData,
@@ -650,14 +565,13 @@ async function processNestedUpdate(
650
565
  originalItem,
651
566
  )
652
567
 
653
- // Field validation (built-in rules)
654
568
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'update')
655
569
  if (validation.errors.length > 0) {
656
570
  throw new ValidationError(validation.errors, validation.fieldErrors)
657
571
  }
658
572
 
659
- // Split multi-column fields into physical columns (#789). Only reached
660
- // once the logical value has passed validation above.
573
+ // Split multi-column fields into physical columns (#789) must run after
574
+ // validation, which operates on the logical (pre-split) value.
661
575
  resolvedData = await splitMultiColumnFields(
662
576
  updateData,
663
577
  resolvedData,
@@ -667,7 +581,6 @@ async function processNestedUpdate(
667
581
  originalItem,
668
582
  )
669
583
 
670
- // Filter writable fields
671
584
  const filtered = await filterWritableFields(
672
585
  resolvedData,
673
586
  relatedListConfig.fields,
@@ -680,8 +593,8 @@ async function processNestedUpdate(
680
593
  },
681
594
  )
682
595
 
683
- // Recursively process nested operations. This nested row is being UPDATED,
684
- // so its own relations' pre-existing rows are captured from `originalItem`.
596
+ // This nested row is being updated, so its own relations' pre-existing
597
+ // rows are captured from `originalItem` below.
685
598
  const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(
686
599
  filtered,
687
600
  relatedListConfig.fields,
@@ -690,12 +603,11 @@ async function processNestedUpdate(
690
603
  'update',
691
604
  relatedListName,
692
605
  originalItem,
693
- // This nested row is being UPDATED, so its enclosing inputData is its own
694
- // update payload (passed to the connect-site owning-field gate, #588).
606
+ // Its own update payload is the enclosing inputData for the connect-site
607
+ // owning-field gate (#588).
695
608
  updateData,
696
609
  )
697
610
 
698
- // Field-level beforeOperation (side effects)
699
611
  await executeFieldBeforeOperationHooks(
700
612
  updateData,
701
613
  resolvedData,
@@ -706,7 +618,6 @@ async function processNestedUpdate(
706
618
  originalItem,
707
619
  )
708
620
 
709
- // List-level beforeOperation
710
621
  await executeBeforeOperation(relatedListConfig.hooks, {
711
622
  listKey: relatedListName,
712
623
  operation: 'update',
@@ -716,8 +627,7 @@ async function processNestedUpdate(
716
627
  context,
717
628
  })
718
629
 
719
- // Register afterOperation: fires after the parent persist. The updated row
720
- // is recovered from the parent's included relation by its known id.
630
+ // Fires after the parent persist; the row is recovered by its known id.
721
631
  afterTasks.push({
722
632
  fieldName,
723
633
  run: async (parentResult) => {
@@ -798,7 +708,6 @@ async function processNestedDelete(
798
708
  throw new Error('Cannot delete: Item not found')
799
709
  }
800
710
 
801
- // Check delete access (skip if sudo mode)
802
711
  if (!context._isSudo) {
803
712
  const deleteAccess = relatedListConfig.access?.operation?.delete
804
713
  const accessResult = await checkAccess(deleteAccess, {
@@ -812,7 +721,6 @@ async function processNestedDelete(
812
721
  }
813
722
  }
814
723
 
815
- // List-level validate (delete)
816
724
  await executeValidate(relatedListConfig.hooks, {
817
725
  listKey: relatedListName,
818
726
  operation: 'delete',
@@ -820,7 +728,6 @@ async function processNestedDelete(
820
728
  context,
821
729
  })
822
730
 
823
- // Field-level validate (delete)
824
731
  await executeFieldValidateHooks(
825
732
  undefined,
826
733
  undefined,
@@ -831,7 +738,6 @@ async function processNestedDelete(
831
738
  originalItem,
832
739
  )
833
740
 
834
- // Field-level beforeOperation (delete)
835
741
  await executeFieldBeforeOperationHooks(
836
742
  {},
837
743
  {},
@@ -842,7 +748,6 @@ async function processNestedDelete(
842
748
  originalItem,
843
749
  )
844
750
 
845
- // List-level beforeOperation (delete)
846
751
  await executeBeforeOperation(relatedListConfig.hooks, {
847
752
  listKey: relatedListName,
848
753
  operation: 'delete',
@@ -850,8 +755,8 @@ async function processNestedDelete(
850
755
  context,
851
756
  })
852
757
 
853
- // Register afterOperation: the row is gone after persist, so the
854
- // originalItem is the authoritative record passed to after-hooks.
758
+ // The row is gone after persist, so originalItem is the authoritative
759
+ // record passed to the after-hooks below.
855
760
  afterTasks.push({
856
761
  fieldName: '',
857
762
  run: async () => {
@@ -880,9 +785,6 @@ async function processNestedDelete(
880
785
  return deletes
881
786
  }
882
787
 
883
- /**
884
- * Process nested connectOrCreate operations
885
- */
886
788
  async function processNestedConnectOrCreate(
887
789
  operations: Record<string, unknown> | Array<Record<string, unknown>>,
888
790
  fieldName: string,
@@ -905,15 +807,11 @@ async function processNestedConnectOrCreate(
905
807
  operationsArray.map(async (op) => {
906
808
  const opRecord = op as Record<string, unknown>
907
809
 
908
- // Check access for the connect portion (skip if sudo mode).
909
- //
910
- // connectOrCreate connects an existing row when present, otherwise
911
- // creates. So when the row exists we apply the same connect semantics as
912
- // processNestedConnect READ (query) access on the target, evaluated via
913
- // DB reachability for filter results, PLUS the owning relationship field's
914
- // field-level access (#588). When the row does not exist we fall through to
915
- // create. We must NOT swallow an access-denied error: only the genuine
916
- // "row absent" case may fall back to create.
810
+ // When the target row exists, connectOrCreate applies the same connect
811
+ // semantics as processNestedConnect (read access + #588 owning-field gate,
812
+ // both evaluated via DB reachability); otherwise it falls through to
813
+ // create. An access-denied error must NOT be swallowed into that fallback
814
+ // — only a genuine "row absent" may fall back to create.
917
815
  let rowExists = false
918
816
  if (!context._isSudo) {
919
817
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -922,18 +820,9 @@ async function processNestedConnectOrCreate(
922
820
 
923
821
  const existingItem = await model.findUnique({ where })
924
822
 
925
- // Only enforce connect access when the row actually exists; otherwise
926
- // the create branch is used.
927
823
  if (existingItem) {
928
824
  rowExists = true
929
825
 
930
- // #588 — gate the connect branch by the OWNING relationship field's
931
- // field-level access, identical to processNestedConnect. A deny here
932
- // denies the connect even if the target row is readable/reachable.
933
- // `item`/`inputData` are the ENCLOSING write's `originalItem`/
934
- // `inputData` (the same values Phase-5 `filterWritableFields` passes),
935
- // so item-/inputData-dependent field rules cannot diverge between the
936
- // two gates.
937
826
  const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
938
827
  session: context.session,
939
828
  item: enclosingItem,
@@ -955,8 +844,6 @@ async function processNestedConnectOrCreate(
955
844
  throw new Error('Access denied: Cannot connect to existing item')
956
845
  }
957
846
 
958
- // Filter result: confirm the existing row is reachable under the
959
- // access filter via DB reachability (handles nested/boolean filters).
960
847
  if (accessResult !== true) {
961
848
  const reachable = await model.findFirst({
962
849
  where: { AND: [where, accessResult] },
@@ -969,11 +856,10 @@ async function processNestedConnectOrCreate(
969
856
  }
970
857
  }
971
858
 
972
- // Process the create portion through the full create pipeline (incl.
973
- // before/afterOperation). Only register an afterOperation task when the
974
- // create branch will actually run (row absent), so a pure connect does not
975
- // fire create hooks. Under sudo we cannot statically know, so we let the
976
- // create pipeline run its hooks (sudo bypasses access only, not hooks).
859
+ // Only register an afterOperation task when the create branch will
860
+ // actually run (row absent), so a pure connect doesn't fire create hooks.
861
+ // Under sudo we can't tell statically, so the create pipeline always runs
862
+ // its hooks there (sudo bypasses access only, not hooks).
977
863
  const runCreateHooks = context._isSudo || !rowExists
978
864
  const createAfterTasks: AfterTask[] = runCreateHooks ? afterTasks : []
979
865
  const processedCreate = await processNestedCreate(
@@ -1011,29 +897,20 @@ interface NestedOpHandlerArgs {
1011
897
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
1012
898
  relatedListConfig: ListConfig<any>
1013
899
  /**
1014
- * Field-level `access` of the OWNING relationship field on the list being
1015
- * written (e.g. `Post.author`). Used by the connect/connectOrCreate handlers
1016
- * to gate connects by the owning field's create/update access (#588).
900
+ * Field-level `access` of the OWNING relationship field (e.g. `Post.author`).
901
+ * Gates connect/connectOrCreate by the owning field's create/update access —
902
+ * see the #588 gate in {@link verifyConnectReachable}.
1017
903
  */
1018
904
  owningFieldAccess: FieldAccess | undefined
1019
- /**
1020
- * The enclosing write's operation (`create`/`update`), used as the field-access
1021
- * operation for the owning-field connect gate (#588).
1022
- */
905
+ /** The enclosing write's operation, used as the owning-field gate's field-access operation. */
1023
906
  enclosingOperation: 'create' | 'update'
1024
907
  /**
1025
- * The enclosing write's existing row (the parent `originalItem`): present for an
1026
- * enclosing UPDATE, `undefined` for an enclosing CREATE. Threaded into the
1027
- * connect-site owning-field gate so it evaluates `item` exactly like the
1028
- * canonical Phase-5 `filterWritableFields` call and the two cannot diverge
1029
- * (#588 finding).
908
+ * The enclosing write's `originalItem` (`undefined` for an enclosing create).
909
+ * Passed to the owning-field gate so it matches the canonical Phase-5
910
+ * `filterWritableFields` call and the two can't diverge (#588).
1030
911
  */
1031
912
  enclosingItem: Record<string, unknown> | undefined
1032
- /**
1033
- * The enclosing write's input data. Threaded into the connect-site owning-field
1034
- * gate so it evaluates `inputData` exactly like the canonical Phase-5
1035
- * `filterWritableFields` call (#588 finding).
1036
- */
913
+ /** The enclosing write's input data, passed to the owning-field gate for the same reason as `enclosingItem`. */
1037
914
  enclosingInputData: Record<string, unknown> | undefined
1038
915
  context: AccessContext
1039
916
  config: OpenSaasConfig
@@ -1307,10 +1184,8 @@ export async function processNestedOperations(
1307
1184
  operation: 'create' | 'update',
1308
1185
  parentListName: string,
1309
1186
  parentOriginalItem: Record<string, unknown> | undefined,
1310
- // The enclosing write's input data (the SAME value Phase-5 `filterWritableFields`
1311
- // passes as `inputData`). Threaded into the connect-site owning-field gate (#588
1312
- // finding) so item-/inputData-dependent field-access rules cannot diverge between
1313
- // Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
1187
+ // The enclosing write's inputData, for the #588 owning-field gate (see
1188
+ // verifyConnectReachable). `undefined` is tolerated (defaults to `{}`).
1314
1189
  parentInputData: Record<string, unknown> | undefined = undefined,
1315
1190
  ): Promise<NestedOpsResult> {
1316
1191
  const afterTasks: AfterTask[] = []
@@ -1321,13 +1196,11 @@ export async function processNestedOperations(
1321
1196
  for (const [fieldName, value] of Object.entries(data)) {
1322
1197
  const fieldConfig = fieldConfigs[fieldName]
1323
1198
 
1324
- // If not a relationship field or no value, pass through
1325
1199
  if (!isRelationshipField(fieldConfig) || value === null || value === undefined) {
1326
1200
  processed[fieldName] = value
1327
1201
  continue
1328
1202
  }
1329
1203
 
1330
- // Get related list config
1331
1204
  const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
1332
1205
  const relatedConfig = getRelatedListConfig(relationshipField.ref, config)
1333
1206
  if (!relatedConfig) {
@@ -1336,13 +1209,10 @@ export async function processNestedOperations(
1336
1209
  }
1337
1210
 
1338
1211
  const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig
1339
- // Sanity: ensure the resolved list name matches the config identity.
1340
1212
  const resolvedListName = relatedListName || findListName(relatedListConfig, config)
1341
1213
 
1342
- // #588 — the owning relationship field's field-level access (e.g. the
1343
- // `access` on `Post.author`). Threaded into the nested-op handlers so the
1344
- // connect/connectOrCreate handlers can gate connects by this field's
1345
- // create/update access, in addition to the target's read access.
1214
+ // The owning relationship field's field-level access, for the #588 gate
1215
+ // in verifyConnectReachable.
1346
1216
  const owningFieldAccess = fieldConfig.access
1347
1217
 
1348
1218
  processed[fieldName] = await processFieldNestedOps(
@@ -1353,10 +1223,8 @@ export async function processNestedOperations(
1353
1223
  relatedListConfig,
1354
1224
  owningFieldAccess,
1355
1225
  enclosingOperation: operation,
1356
- // The enclosing write's `originalItem`/`inputData` the SAME values the
1357
- // canonical Phase-5 `filterWritableFields` call passes for this field — so
1358
- // the connect-site owning-field gate evaluates item-/inputData-dependent
1359
- // rules identically and cannot diverge into a spurious connect denial (#588).
1226
+ // Same values Phase-5 `filterWritableFields` passes for this field, so
1227
+ // the #588 owning-field gate can't diverge from it.
1360
1228
  enclosingItem: parentOriginalItem,
1361
1229
  enclosingInputData: parentInputData ?? {},
1362
1230
  context,