@opensaas/stack-core 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -3,15 +3,10 @@ import { checkFieldAccess } from '../access/field-access.js';
3
3
  import { executeResolveInput, executeValidate, executeFieldResolveInputHooks, executeBeforeOperation, executeAfterOperation, executeFieldBeforeOperationHooks, executeFieldAfterOperationHooks, executeFieldValidateHooks, validateFieldRules, splitMultiColumnFields, ValidationError, } from '../hooks/index.js';
4
4
  import { getDbKey } from '../lib/case-utils.js';
5
5
  import { applyCreateDefaults } from './apply-defaults.js';
6
- /**
7
- * Check if a field config is a relationship field
8
- */
9
6
  function isRelationshipField(fieldConfig) {
10
7
  return fieldConfig?.type === 'relationship';
11
8
  }
12
- /**
13
- * Resolve the related list name for a related list config (config object identity).
14
- */
9
+ /** Resolve a list name by matching config object identity, not by name. */
15
10
  function findListName(
16
11
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
17
12
  relatedListConfig, config) {
@@ -26,8 +21,7 @@ relatedListConfig, config) {
26
21
  * Read the rows of a parent's included relation as an array.
27
22
  *
28
23
  * A to-one relation comes back as a single row (or `null`); a to-many relation
29
- * comes back as an array. This normalises both to an array so callers can apply
30
- * a uniform id-diff.
24
+ * comes back as an array. Normalises both so callers can apply a uniform id-diff.
31
25
  */
32
26
  function includedRows(parentResult, fieldName) {
33
27
  const included = parentResult[fieldName];
@@ -49,13 +43,10 @@ function recoverUpdatedRow(parentResult, fieldName, knownId) {
49
43
  return includedRows(parentResult, fieldName).find((r) => r.id === knownId);
50
44
  }
51
45
  /**
52
- * Recover the CREATED nested rows from the parent result by id-diff.
53
- *
54
- * Created rows have no known id before the write, so they are identified as the
55
- * included rows whose ids are NOT in `preExistingIds` (the set of related-row
56
- * ids captured before the persist). Returned in include order, which the create
57
- * handler pairs to its create-payload entries by position (see
58
- * {@link CreatedRowRecovery}).
46
+ * Recover the CREATED nested rows from the parent result by id-diff: the
47
+ * included rows whose ids are NOT in `preExistingIds`. Returned in include
48
+ * order, which {@link CreatedRowRecovery} pairs to create-payload entries by
49
+ * position reordering this would break that pairing.
59
50
  */
60
51
  function recoverCreatedRows(parentResult, fieldName, preExistingIds) {
61
52
  return includedRows(parentResult, fieldName).filter((r) => typeof r.id === 'string' && !preExistingIds.has(r.id));
@@ -76,14 +67,11 @@ function createCreatedRowRecovery(fieldName, preExistingIds) {
76
67
  }
77
68
  /**
78
69
  * Capture the ids of the rows currently linked to the parent via `fieldName`,
79
- * BEFORE the parent persists. Used to identify which included rows are NEW
80
- * (created by this write) afterwards.
70
+ * BEFORE the parent persists, so created rows can later be identified by id-diff.
71
+ * Empty for a parent CREATE (no parent row exists yet to have related rows).
81
72
  *
82
- * - For a parent CREATE there are no pre-existing related rows (the parent does
83
- * not exist yet), so the set is empty.
84
- * - For a parent UPDATE we read the parent row's current relation and collect
85
- * its ids. The same `tx` client is used so the read participates in the
86
- * transaction and sees a consistent snapshot.
73
+ * `prisma` must be the transaction client the read has to participate in the
74
+ * transaction to see a consistent snapshot.
87
75
  */
88
76
  async function capturePreExistingIds(parentListName, parentOriginalItem, fieldName, prisma) {
89
77
  const ids = new Set();
@@ -108,18 +96,15 @@ async function capturePreExistingIds(parentListName, parentOriginalItem, fieldNa
108
96
  return ids;
109
97
  }
110
98
  /**
111
- * Process nested create operations.
112
- *
113
- * Runs the target list's full input pipeline (resolveInput → validate →
114
- * field-rules → filter-writable → recurse) AND its `beforeOperation`, then
115
- * registers an `afterOperation` task keyed to the parent's included relation.
99
+ * Process nested create operations: run the target list's full input pipeline
100
+ * and `beforeOperation`, then register an `afterOperation` task keyed to the
101
+ * parent's included relation.
116
102
  */
117
103
  async function processNestedCreate(items, fieldName, relatedListName,
118
104
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
119
105
  relatedListConfig, context, config, prisma, afterTasks, recovery) {
120
106
  const itemsArray = Array.isArray(items) ? items : [items];
121
107
  const processedItems = await Promise.all(itemsArray.map(async (item, index) => {
122
- // 1. Check create access (skip if sudo mode)
123
108
  if (!context._isSudo) {
124
109
  const createAccess = relatedListConfig.access?.operation?.create;
125
110
  const accessResult = await checkAccess(createAccess, {
@@ -130,7 +115,6 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
130
115
  throw new Error('Access denied: Cannot create related item');
131
116
  }
132
117
  }
133
- // 2. Execute list-level resolveInput hook
134
118
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
135
119
  listKey: relatedListName,
136
120
  operation: 'create',
@@ -139,14 +123,11 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
139
123
  item: undefined,
140
124
  context,
141
125
  });
142
- // 3. Execute field-level resolveInput hooks
143
126
  resolvedData = await executeFieldResolveInputHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
144
- // 3.5 Apply field defaults to omitted inputs (resolve-then-validate, #615).
145
- // Mirrors the top-level Hook Pipeline so a nested required-with-default
146
- // field resolves to its default before validation instead of failing
147
- // `isRequired`. Create-only; explicit values (incl. null) are preserved.
127
+ // Apply field defaults to omitted inputs (resolve-then-validate, #615) so
128
+ // a nested required-with-default field resolves before `isRequired` runs,
129
+ // mirroring the top-level pipeline. Create-only.
148
130
  resolvedData = applyCreateDefaults(resolvedData, relatedListConfig.fields);
149
- // 4. Execute validate hook
150
131
  await executeValidate(relatedListConfig.hooks, {
151
132
  listKey: relatedListName,
152
133
  operation: 'create',
@@ -155,32 +136,26 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
155
136
  item: undefined,
156
137
  context,
157
138
  });
158
- // 4.5 Field-level validate hooks
159
139
  await executeFieldValidateHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
160
- // 5. Field validation (built-in rules)
161
140
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'create');
162
141
  if (validation.errors.length > 0) {
163
142
  throw new ValidationError(validation.errors, validation.fieldErrors);
164
143
  }
165
- // 5.5 Split multi-column fields into physical columns (#789). Only
166
- // reached once the logical value has passed validation above.
144
+ // Split multi-column fields into physical columns (#789) — must run after
145
+ // validation, which operates on the logical (pre-split) value.
167
146
  resolvedData = await splitMultiColumnFields(item, resolvedData, relatedListConfig.fields, 'create', context);
168
- // 6. Filter writable fields
169
147
  const filtered = await filterWritableFields(resolvedData, relatedListConfig.fields, 'create', {
170
148
  session: context.session,
171
149
  context,
172
150
  inputData: item,
173
151
  });
174
- // 7. Recursively process nested operations in this item. This nested row
175
- // is itself being CREATED, so its own relations have no pre-existing rows
176
- // (parent originalItem is undefined → empty pre-existing set).
152
+ // This nested row is itself being created, so it has no pre-existing
153
+ // related rows of its own (parent originalItem is undefined below).
177
154
  const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(filtered, relatedListConfig.fields, config, { ...context, prisma }, 'create', relatedListName, undefined,
178
- // This nested row is being CREATED, so its enclosing inputData is its own
179
- // create payload (passed to the connect-site owning-field gate, #588).
155
+ // Its own create payload is the enclosing inputData for the connect-site
156
+ // owning-field gate (#588).
180
157
  item);
181
- // 8. Field-level beforeOperation (side effects) for this nested create
182
158
  await executeFieldBeforeOperationHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
183
- // 9. List-level beforeOperation for this nested create
184
159
  await executeBeforeOperation(relatedListConfig.hooks, {
185
160
  listKey: relatedListName,
186
161
  operation: 'create',
@@ -188,26 +163,19 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
188
163
  resolvedData,
189
164
  context,
190
165
  });
191
- // 10. Register afterOperation: fires once the parent (and thus this nested
192
- // row) has persisted. The created row is recovered by id-diff and paired
193
- // to THIS create-payload entry by position (see CreatedRowRecovery), so a
194
- // to-many `create: [{A},{B}]` fires once per row, each against its OWN
195
- // distinct row, and never against a pre-existing sibling.
166
+ // Fires once the parent has persisted. The row is recovered by id-diff and
167
+ // paired to THIS entry by position (see CreatedRowRecovery), so a to-many
168
+ // `create: [{A},{B}]` fires once per row against its own distinct row.
196
169
  afterTasks.push({
197
170
  fieldName,
198
171
  run: async (parentResult) => {
199
172
  const createdItem = recovery.rowAt(parentResult, index);
200
173
  if (!createdItem) {
201
- // The created row could not be identified by id-diff — the parent
202
- // write did not return this nested relation (e.g. the underlying
203
- // client does not echo `include`d relations). We must NOT hand an
204
- // id-less `{}` to a hook as if it were the persisted row (finding 4:
205
- // that would fire `afterOperation` against a fabricated item). The
206
- // before-persist hooks have already run; we deliberately SKIP this
207
- // record's create `afterOperation` rather than fire it with a bogus
208
- // item. Real Prisma always echoes the `include`d relation, so this
209
- // skip is reached only by clients/mocks that omit it. `item`
210
- // correctness is the must-have; a missing row is never fabricated.
174
+ // Could not identify the created row by id-diff — the parent write
175
+ // didn't echo this nested relation (e.g. a client/mock that doesn't
176
+ // honour `include`; real Prisma always does). Deliberately SKIP this
177
+ // record's `afterOperation` rather than fabricate an id-less item
178
+ // `item` correctness is the must-have, so a missing row is never faked.
211
179
  return;
212
180
  }
213
181
  await executeAfterOperation(relatedListConfig.hooks, {
@@ -219,7 +187,6 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
219
187
  context,
220
188
  });
221
189
  await executeFieldAfterOperationHooks(createdItem, item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
222
- // Run any deeper nested afterOperation tasks, scoped to the persisted row.
223
190
  await runAfterTasks(childAfterTasks, createdItem);
224
191
  },
225
192
  });
@@ -230,23 +197,18 @@ relatedListConfig, context, config, prisma, afterTasks, recovery) {
230
197
  /**
231
198
  * Verify that a single connection target is reachable for the caller.
232
199
  *
233
- * Connecting an existing row references it; it does not modify the row's own
234
- * data. Mirroring Keystone, this requires **read/query** access on the target
235
- * list (not `update`). When query access returns a filter object, the filter is
236
- * evaluated in the DATABASE (not in memory) via
237
- * `findFirst({ where: { AND: [connection, accessFilter] } })`. The connect is
238
- * allowed iff that query returns a row, which correctly handles arbitrary
239
- * nested-relation predicates and boolean combinators (`AND`/`OR`/`some`/
240
- * `none`/`not`). The existence check is folded into the reachability query so a
241
- * non-existent id is still denied.
200
+ * Connecting references an existing row rather than modifying it, so mirroring
201
+ * Keystone it requires **read/query** access on the target (#578), not update.
202
+ * A filter-result query access is evaluated in the DATABASE via
203
+ * `findFirst({ where: { AND: [connection, accessFilter] } })` rather than in
204
+ * memory, so it correctly handles arbitrary nested-relation predicates and
205
+ * boolean combinators; a non-existent id is folded into the same check.
242
206
  *
243
- * In ADDITION to the target read/reachability check (#578), the OWNING
244
- * relationship field's field-level access (its `create`/`update` access on the
245
- * list being written, e.g. `Post.author`) must permit the connect (#588). This
246
- * is the other half Keystone required: a connect needs read access on the
247
- * target AND write access on the owning relationship field. If the owning
248
- * field's field-level access denies, the connect is denied even when the target
249
- * row is readable/reachable.
207
+ * In ADDITION, the OWNING relationship field's field-level access (e.g.
208
+ * `Post.author`'s `create`/`update` access) must permit the connect (#588) —
209
+ * the other half Keystone requires: read access on the target AND write access
210
+ * on the owning field. A deny here denies the connect even when the target row
211
+ * is readable/reachable.
250
212
  *
251
213
  * Sudo bypasses the entire check (handled by the caller).
252
214
  */
@@ -256,18 +218,10 @@ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclo
256
218
  // Access Prisma model dynamically - required because model names are generated at runtime
257
219
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
220
  const model = prisma[getDbKey(relatedListName)];
259
- // #588 gate the connect by the OWNING relationship field's field-level
260
- // access (evaluated for the enclosing write's operation). This runs in
261
- // addition to the target read/reachability check below; a deny here denies
262
- // the connect even if the target row is readable. `checkFieldAccess` returns
263
- // `true` under sudo, but the caller already skips this whole function for
264
- // sudo, so the gate never fires for trusted writes.
265
- //
266
- // `item`/`inputData` are the ENCLOSING write's `originalItem`/`inputData` —
267
- // the SAME values the canonical Phase-5 `filterWritableFields` call passes for
268
- // this field — so a field-access rule that depends on `item` or `inputData`
269
- // (e.g. `({ item }) => item.status === 'draft'`) evaluates identically here and
270
- // at Phase 5, and the two gates cannot diverge into a spurious connect denial.
221
+ // #588 owning-field gate (see docblock above). `item`/`inputData` are the
222
+ // ENCLOSING write's `originalItem`/`inputData` the same values the canonical
223
+ // Phase-5 `filterWritableFields` call passes for this field so an
224
+ // item-/inputData-dependent field-access rule can't diverge between the two gates.
271
225
  const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
272
226
  session: context.session,
273
227
  item: enclosingItem,
@@ -277,18 +231,15 @@ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclo
277
231
  if (!owningFieldAllowed) {
278
232
  throw new Error('Access denied: Cannot connect to this item');
279
233
  }
280
- // Connecting references an existing row; it requires READ (query) access on
281
- // the target, not update access.
282
234
  const queryAccess = relatedListConfig.access?.operation?.query;
283
235
  const accessResult = await checkAccess(queryAccess, {
284
236
  session: context.session,
285
237
  context,
286
238
  });
287
- // Explicit denial.
288
239
  if (accessResult === false) {
289
240
  throw new Error('Access denied: Cannot connect to this item');
290
241
  }
291
- // Full access: still verify the row exists (keep "Item not found" behaviour).
242
+ // Full access still verifies the row exists, to keep "Item not found" behaviour.
292
243
  if (accessResult === true) {
293
244
  const item = await model.findUnique({ where: connection });
294
245
  if (!item) {
@@ -296,11 +247,8 @@ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclo
296
247
  }
297
248
  return;
298
249
  }
299
- // Filter result: confirm the row is reachable under the access filter by
300
- // AND-combining the connection identifier with the filter and querying the DB.
301
- // A non-existent id and an unreachable row both yield no row → denied. This
302
- // correctly evaluates arbitrary nested-relation predicates and boolean
303
- // combinators because the database does the matching, not an in-memory walk.
250
+ // Filter result: reachable iff AND-combining the connection with the filter
251
+ // returns a row (see docblock above).
304
252
  const reachable = await model.findFirst({
305
253
  where: { AND: [connection, accessResult] },
306
254
  });
@@ -317,7 +265,6 @@ async function processNestedConnect(connections, relatedListName,
317
265
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
318
266
  relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData) {
319
267
  const connectionsArray = Array.isArray(connections) ? connections : [connections];
320
- // Check read access for each item being connected (skip if sudo mode)
321
268
  if (!context._isSudo) {
322
269
  for (const connection of connectionsArray) {
323
270
  await verifyConnectReachable(connection, relatedListName, relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData);
@@ -341,15 +288,12 @@ relatedListConfig, context, config, prisma, afterTasks) {
341
288
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
342
289
  const model = prisma[getDbKey(relatedListName)];
343
290
  const where = update.where;
344
- // Fetch the existing item — reused as `originalItem` for afterOperation.
291
+ // Fetched here and reused as `originalItem` for afterOperation.
345
292
  const originalItem = await model.findUnique({ where });
346
293
  if (!originalItem) {
347
294
  throw new Error('Cannot update: Item not found');
348
295
  }
349
- // The updated row's id is known up front, so the included-result read-back
350
- // finds this row directly by id.
351
296
  const knownId = typeof originalItem.id === 'string' ? originalItem.id : undefined;
352
- // Check update access (skip if sudo mode)
353
297
  if (!context._isSudo) {
354
298
  const updateAccess = relatedListConfig.access?.operation?.update;
355
299
  const accessResult = await checkAccess(updateAccess, {
@@ -361,7 +305,6 @@ relatedListConfig, context, config, prisma, afterTasks) {
361
305
  throw new Error('Access denied: Cannot update related item');
362
306
  }
363
307
  }
364
- // Execute list-level resolveInput hook
365
308
  const updateData = update.data;
366
309
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
367
310
  listKey: relatedListName,
@@ -371,9 +314,7 @@ relatedListConfig, context, config, prisma, afterTasks) {
371
314
  item: originalItem,
372
315
  context,
373
316
  });
374
- // Execute field-level resolveInput hooks
375
317
  resolvedData = await executeFieldResolveInputHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
376
- // Execute validate hook
377
318
  await executeValidate(relatedListConfig.hooks, {
378
319
  listKey: relatedListName,
379
320
  operation: 'update',
@@ -382,32 +323,27 @@ relatedListConfig, context, config, prisma, afterTasks) {
382
323
  item: originalItem,
383
324
  context,
384
325
  });
385
- // Field-level validate hooks
386
326
  await executeFieldValidateHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
387
- // Field validation (built-in rules)
388
327
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'update');
389
328
  if (validation.errors.length > 0) {
390
329
  throw new ValidationError(validation.errors, validation.fieldErrors);
391
330
  }
392
- // Split multi-column fields into physical columns (#789). Only reached
393
- // once the logical value has passed validation above.
331
+ // Split multi-column fields into physical columns (#789) must run after
332
+ // validation, which operates on the logical (pre-split) value.
394
333
  resolvedData = await splitMultiColumnFields(updateData, resolvedData, relatedListConfig.fields, 'update', context, originalItem);
395
- // Filter writable fields
396
334
  const filtered = await filterWritableFields(resolvedData, relatedListConfig.fields, 'update', {
397
335
  session: context.session,
398
336
  item: originalItem,
399
337
  context,
400
338
  inputData: updateData,
401
339
  });
402
- // Recursively process nested operations. This nested row is being UPDATED,
403
- // so its own relations' pre-existing rows are captured from `originalItem`.
340
+ // This nested row is being updated, so its own relations' pre-existing
341
+ // rows are captured from `originalItem` below.
404
342
  const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(filtered, relatedListConfig.fields, config, { ...context, prisma }, 'update', relatedListName, originalItem,
405
- // This nested row is being UPDATED, so its enclosing inputData is its own
406
- // update payload (passed to the connect-site owning-field gate, #588).
343
+ // Its own update payload is the enclosing inputData for the connect-site
344
+ // owning-field gate (#588).
407
345
  updateData);
408
- // Field-level beforeOperation (side effects)
409
346
  await executeFieldBeforeOperationHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
410
- // List-level beforeOperation
411
347
  await executeBeforeOperation(relatedListConfig.hooks, {
412
348
  listKey: relatedListName,
413
349
  operation: 'update',
@@ -416,8 +352,7 @@ relatedListConfig, context, config, prisma, afterTasks) {
416
352
  resolvedData,
417
353
  context,
418
354
  });
419
- // Register afterOperation: fires after the parent persist. The updated row
420
- // is recovered from the parent's included relation by its known id.
355
+ // Fires after the parent persist; the row is recovered by its known id.
421
356
  afterTasks.push({
422
357
  fieldName,
423
358
  run: async (parentResult) => {
@@ -470,7 +405,6 @@ relatedListConfig, context, prisma, afterTasks) {
470
405
  if (!originalItem) {
471
406
  throw new Error('Cannot delete: Item not found');
472
407
  }
473
- // Check delete access (skip if sudo mode)
474
408
  if (!context._isSudo) {
475
409
  const deleteAccess = relatedListConfig.access?.operation?.delete;
476
410
  const accessResult = await checkAccess(deleteAccess, {
@@ -482,26 +416,22 @@ relatedListConfig, context, prisma, afterTasks) {
482
416
  throw new Error('Access denied: Cannot delete related item');
483
417
  }
484
418
  }
485
- // List-level validate (delete)
486
419
  await executeValidate(relatedListConfig.hooks, {
487
420
  listKey: relatedListName,
488
421
  operation: 'delete',
489
422
  item: originalItem,
490
423
  context,
491
424
  });
492
- // Field-level validate (delete)
493
425
  await executeFieldValidateHooks(undefined, undefined, relatedListConfig.fields, 'delete', context, relatedListName, originalItem);
494
- // Field-level beforeOperation (delete)
495
426
  await executeFieldBeforeOperationHooks({}, {}, relatedListConfig.fields, 'delete', context, relatedListName, originalItem);
496
- // List-level beforeOperation (delete)
497
427
  await executeBeforeOperation(relatedListConfig.hooks, {
498
428
  listKey: relatedListName,
499
429
  operation: 'delete',
500
430
  item: originalItem,
501
431
  context,
502
432
  });
503
- // Register afterOperation: the row is gone after persist, so the
504
- // originalItem is the authoritative record passed to after-hooks.
433
+ // The row is gone after persist, so originalItem is the authoritative
434
+ // record passed to the after-hooks below.
505
435
  afterTasks.push({
506
436
  fieldName: '',
507
437
  run: async () => {
@@ -517,41 +447,25 @@ relatedListConfig, context, prisma, afterTasks) {
517
447
  }));
518
448
  return deletes;
519
449
  }
520
- /**
521
- * Process nested connectOrCreate operations
522
- */
523
450
  async function processNestedConnectOrCreate(operations, fieldName, relatedListName,
524
451
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
525
452
  relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData) {
526
453
  const operationsArray = Array.isArray(operations) ? operations : [operations];
527
454
  const processedOps = await Promise.all(operationsArray.map(async (op) => {
528
455
  const opRecord = op;
529
- // Check access for the connect portion (skip if sudo mode).
530
- //
531
- // connectOrCreate connects an existing row when present, otherwise
532
- // creates. So when the row exists we apply the same connect semantics as
533
- // processNestedConnect READ (query) access on the target, evaluated via
534
- // DB reachability for filter results, PLUS the owning relationship field's
535
- // field-level access (#588). When the row does not exist we fall through to
536
- // create. We must NOT swallow an access-denied error: only the genuine
537
- // "row absent" case may fall back to create.
456
+ // When the target row exists, connectOrCreate applies the same connect
457
+ // semantics as processNestedConnect (read access + #588 owning-field gate,
458
+ // both evaluated via DB reachability); otherwise it falls through to
459
+ // create. An access-denied error must NOT be swallowed into that fallback
460
+ // — only a genuine "row absent" may fall back to create.
538
461
  let rowExists = false;
539
462
  if (!context._isSudo) {
540
463
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
464
  const model = prisma[getDbKey(relatedListName)];
542
465
  const where = opRecord.where;
543
466
  const existingItem = await model.findUnique({ where });
544
- // Only enforce connect access when the row actually exists; otherwise
545
- // the create branch is used.
546
467
  if (existingItem) {
547
468
  rowExists = true;
548
- // #588 — gate the connect branch by the OWNING relationship field's
549
- // field-level access, identical to processNestedConnect. A deny here
550
- // denies the connect even if the target row is readable/reachable.
551
- // `item`/`inputData` are the ENCLOSING write's `originalItem`/
552
- // `inputData` (the same values Phase-5 `filterWritableFields` passes),
553
- // so item-/inputData-dependent field rules cannot diverge between the
554
- // two gates.
555
469
  const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
556
470
  session: context.session,
557
471
  item: enclosingItem,
@@ -570,8 +484,6 @@ relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAcc
570
484
  if (accessResult === false) {
571
485
  throw new Error('Access denied: Cannot connect to existing item');
572
486
  }
573
- // Filter result: confirm the existing row is reachable under the
574
- // access filter via DB reachability (handles nested/boolean filters).
575
487
  if (accessResult !== true) {
576
488
  const reachable = await model.findFirst({
577
489
  where: { AND: [where, accessResult] },
@@ -582,11 +494,10 @@ relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAcc
582
494
  }
583
495
  }
584
496
  }
585
- // Process the create portion through the full create pipeline (incl.
586
- // before/afterOperation). Only register an afterOperation task when the
587
- // create branch will actually run (row absent), so a pure connect does not
588
- // fire create hooks. Under sudo we cannot statically know, so we let the
589
- // create pipeline run its hooks (sudo bypasses access only, not hooks).
497
+ // Only register an afterOperation task when the create branch will
498
+ // actually run (row absent), so a pure connect doesn't fire create hooks.
499
+ // Under sudo we can't tell statically, so the create pipeline always runs
500
+ // its hooks there (sudo bypasses access only, not hooks).
590
501
  const runCreateHooks = context._isSudo || !rowExists;
591
502
  const createAfterTasks = runCreateHooks ? afterTasks : [];
592
503
  const processedCreate = await processNestedCreate(opRecord.create, fieldName, relatedListName, relatedListConfig, context, config, prisma, createAfterTasks, recovery);
@@ -708,22 +619,18 @@ async function processFieldNestedOps(fieldName, valueRecord, args, includeFields
708
619
  * parent persist via {@link runAfterTasks}). See ADR-0010.
709
620
  */
710
621
  export async function processNestedOperations(data, fieldConfigs, config, context, operation, parentListName, parentOriginalItem,
711
- // The enclosing write's input data (the SAME value Phase-5 `filterWritableFields`
712
- // passes as `inputData`). Threaded into the connect-site owning-field gate (#588
713
- // finding) so item-/inputData-dependent field-access rules cannot diverge between
714
- // Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
622
+ // The enclosing write's inputData, for the #588 owning-field gate (see
623
+ // verifyConnectReachable). `undefined` is tolerated (defaults to `{}`).
715
624
  parentInputData = undefined) {
716
625
  const afterTasks = [];
717
626
  const includeFields = new Set();
718
627
  const processed = {};
719
628
  for (const [fieldName, value] of Object.entries(data)) {
720
629
  const fieldConfig = fieldConfigs[fieldName];
721
- // If not a relationship field or no value, pass through
722
630
  if (!isRelationshipField(fieldConfig) || value === null || value === undefined) {
723
631
  processed[fieldName] = value;
724
632
  continue;
725
633
  }
726
- // Get related list config
727
634
  const relationshipField = fieldConfig;
728
635
  const relatedConfig = getRelatedListConfig(relationshipField.ref, config);
729
636
  if (!relatedConfig) {
@@ -731,22 +638,17 @@ parentInputData = undefined) {
731
638
  continue;
732
639
  }
733
640
  const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig;
734
- // Sanity: ensure the resolved list name matches the config identity.
735
641
  const resolvedListName = relatedListName || findListName(relatedListConfig, config);
736
- // #588 — the owning relationship field's field-level access (e.g. the
737
- // `access` on `Post.author`). Threaded into the nested-op handlers so the
738
- // connect/connectOrCreate handlers can gate connects by this field's
739
- // create/update access, in addition to the target's read access.
642
+ // The owning relationship field's field-level access, for the #588 gate
643
+ // in verifyConnectReachable.
740
644
  const owningFieldAccess = fieldConfig.access;
741
645
  processed[fieldName] = await processFieldNestedOps(fieldName, value, {
742
646
  relatedListName: resolvedListName,
743
647
  relatedListConfig,
744
648
  owningFieldAccess,
745
649
  enclosingOperation: operation,
746
- // The enclosing write's `originalItem`/`inputData` the SAME values the
747
- // canonical Phase-5 `filterWritableFields` call passes for this field — so
748
- // the connect-site owning-field gate evaluates item-/inputData-dependent
749
- // rules identically and cannot diverge into a spurious connect denial (#588).
650
+ // Same values Phase-5 `filterWritableFields` passes for this field, so
651
+ // the #588 owning-field gate can't diverge from it.
750
652
  enclosingItem: parentOriginalItem,
751
653
  enclosingInputData: parentInputData ?? {},
752
654
  context,