@opensaas/stack-core 0.24.0 → 0.25.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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +223 -0
  3. package/dist/access/access-filter.d.ts +39 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +121 -0
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/field-access.d.ts +1 -0
  8. package/dist/access/field-access.d.ts.map +1 -1
  9. package/dist/access/field-access.js +79 -4
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/field-access.test.js +213 -0
  12. package/dist/access/field-access.test.js.map +1 -1
  13. package/dist/access/index.d.ts +1 -1
  14. package/dist/access/index.d.ts.map +1 -1
  15. package/dist/access/index.js +1 -1
  16. package/dist/access/index.js.map +1 -1
  17. package/dist/access/types.d.ts +39 -0
  18. package/dist/access/types.d.ts.map +1 -1
  19. package/dist/config/types.d.ts +318 -0
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/context/index.d.ts +19 -1
  22. package/dist/context/index.d.ts.map +1 -1
  23. package/dist/context/index.js +153 -26
  24. package/dist/context/index.js.map +1 -1
  25. package/dist/context/nested-operations.d.ts +59 -3
  26. package/dist/context/nested-operations.d.ts.map +1 -1
  27. package/dist/context/nested-operations.js +552 -129
  28. package/dist/context/nested-operations.js.map +1 -1
  29. package/dist/context/transaction-boundary.d.ts +91 -0
  30. package/dist/context/transaction-boundary.d.ts.map +1 -0
  31. package/dist/context/transaction-boundary.js +329 -0
  32. package/dist/context/transaction-boundary.js.map +1 -0
  33. package/dist/context/write-pipeline.d.ts +15 -1
  34. package/dist/context/write-pipeline.d.ts.map +1 -1
  35. package/dist/context/write-pipeline.js +173 -10
  36. package/dist/context/write-pipeline.js.map +1 -1
  37. package/dist/fields/calendar-day.test.d.ts +2 -0
  38. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  39. package/dist/fields/calendar-day.test.js +120 -0
  40. package/dist/fields/calendar-day.test.js.map +1 -0
  41. package/dist/fields/index.d.ts +18 -2
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +93 -17
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/hooks/index.d.ts +116 -0
  46. package/dist/hooks/index.d.ts.map +1 -1
  47. package/dist/hooks/index.js +154 -0
  48. package/dist/hooks/index.js.map +1 -1
  49. package/dist/validation/schema.test.js +222 -1
  50. package/dist/validation/schema.test.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/access/access-filter.ts +156 -0
  53. package/src/access/field-access.test.ts +255 -0
  54. package/src/access/field-access.ts +91 -5
  55. package/src/access/index.ts +1 -1
  56. package/src/access/types.ts +45 -0
  57. package/src/config/types.ts +364 -0
  58. package/src/context/index.ts +207 -37
  59. package/src/context/nested-operations.ts +969 -143
  60. package/src/context/transaction-boundary.ts +440 -0
  61. package/src/context/write-pipeline.ts +234 -13
  62. package/src/fields/calendar-day.test.ts +140 -0
  63. package/src/fields/index.ts +96 -16
  64. package/src/hooks/index.ts +265 -0
  65. package/src/validation/schema.test.ts +266 -1
  66. package/tests/access.test.ts +24 -16
  67. package/tests/context.test.ts +481 -0
  68. package/tests/field-types.test.ts +17 -3
  69. package/tests/nested-access-and-hooks.test.ts +1130 -54
  70. package/tests/nested-operation-registry.test.ts +28 -3
  71. package/tests/nested-write-hooks.test.ts +864 -0
  72. package/tests/transaction-boundary-hooks.test.ts +465 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,6 @@
1
1
  import { checkAccess, filterWritableFields, getRelatedListConfig } from '../access/index.js';
2
- import { executeResolveInput, executeValidate, executeFieldResolveInputHooks, validateFieldRules, ValidationError, } from '../hooks/index.js';
2
+ import { checkFieldAccess } from '../access/field-access.js';
3
+ import { executeResolveInput, executeValidate, executeFieldResolveInputHooks, executeBeforeOperation, executeAfterOperation, executeFieldBeforeOperationHooks, executeFieldAfterOperationHooks, executeFieldValidateHooks, validateFieldRules, ValidationError, } from '../hooks/index.js';
3
4
  import { getDbKey } from '../lib/case-utils.js';
4
5
  /**
5
6
  * Check if a field config is a relationship field
@@ -8,14 +9,115 @@ function isRelationshipField(fieldConfig) {
8
9
  return fieldConfig?.type === 'relationship';
9
10
  }
10
11
  /**
11
- * Process nested create operations
12
- * Applies hooks and access control to each item being created
12
+ * Resolve the related list name for a related list config (config object identity).
13
13
  */
14
- async function processNestedCreate(items,
14
+ function findListName(
15
15
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
16
- relatedListConfig, context, config) {
16
+ relatedListConfig, config) {
17
+ for (const [listKey, listCfg] of Object.entries(config.lists)) {
18
+ if (listCfg === relatedListConfig) {
19
+ return listKey;
20
+ }
21
+ }
22
+ return '';
23
+ }
24
+ /**
25
+ * Read the rows of a parent's included relation as an array.
26
+ *
27
+ * A to-one relation comes back as a single row (or `null`); a to-many relation
28
+ * comes back as an array. This normalises both to an array so callers can apply
29
+ * a uniform id-diff.
30
+ */
31
+ function includedRows(parentResult, fieldName) {
32
+ const included = parentResult[fieldName];
33
+ if (included == null)
34
+ return [];
35
+ if (Array.isArray(included))
36
+ return included;
37
+ return [included];
38
+ }
39
+ /**
40
+ * Recover an UPDATED nested row from the parent result by its known id.
41
+ *
42
+ * The updated row's id is known up front (it was fetched for access as
43
+ * `originalItem`), so the persisted row is the included row with that id.
44
+ */
45
+ function recoverUpdatedRow(parentResult, fieldName, knownId) {
46
+ if (knownId === undefined)
47
+ return undefined;
48
+ return includedRows(parentResult, fieldName).find((r) => r.id === knownId);
49
+ }
50
+ /**
51
+ * Recover the CREATED nested rows from the parent result by id-diff.
52
+ *
53
+ * Created rows have no known id before the write, so they are identified as the
54
+ * included rows whose ids are NOT in `preExistingIds` (the set of related-row
55
+ * ids captured before the persist). Returned in include order, which the create
56
+ * handler pairs to its create-payload entries by position (see
57
+ * {@link CreatedRowRecovery}).
58
+ */
59
+ function recoverCreatedRows(parentResult, fieldName, preExistingIds) {
60
+ return includedRows(parentResult, fieldName).filter((r) => typeof r.id === 'string' && !preExistingIds.has(r.id));
61
+ }
62
+ function createCreatedRowRecovery(fieldName, preExistingIds) {
63
+ let cache;
64
+ return {
65
+ rowAt(parentResult, index) {
66
+ if (!cache || cache.source !== parentResult) {
67
+ cache = {
68
+ source: parentResult,
69
+ rows: recoverCreatedRows(parentResult, fieldName, preExistingIds),
70
+ };
71
+ }
72
+ return cache.rows[index];
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Capture the ids of the rows currently linked to the parent via `fieldName`,
78
+ * BEFORE the parent persists. Used to identify which included rows are NEW
79
+ * (created by this write) afterwards.
80
+ *
81
+ * - For a parent CREATE there are no pre-existing related rows (the parent does
82
+ * not exist yet), so the set is empty.
83
+ * - For a parent UPDATE we read the parent row's current relation and collect
84
+ * its ids. The same `tx` client is used so the read participates in the
85
+ * transaction and sees a consistent snapshot.
86
+ */
87
+ async function capturePreExistingIds(parentListName, parentOriginalItem, fieldName, prisma) {
88
+ const ids = new Set();
89
+ const parentId = parentOriginalItem?.id;
90
+ if (typeof parentId !== 'string') {
91
+ // Parent create (no existing row) — nothing pre-exists.
92
+ return ids;
93
+ }
94
+ // Access Prisma model dynamically - required because model names are generated at runtime
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ const parentModel = prisma[getDbKey(parentListName)];
97
+ if (!parentModel?.findUnique)
98
+ return ids;
99
+ const current = await parentModel.findUnique({
100
+ where: { id: parentId },
101
+ include: { [fieldName]: true },
102
+ });
103
+ for (const row of includedRows((current ?? {}), fieldName)) {
104
+ if (typeof row.id === 'string')
105
+ ids.add(row.id);
106
+ }
107
+ return ids;
108
+ }
109
+ /**
110
+ * Process nested create operations.
111
+ *
112
+ * Runs the target list's full input pipeline (resolveInput → validate →
113
+ * field-rules → filter-writable → recurse) AND its `beforeOperation`, then
114
+ * registers an `afterOperation` task keyed to the parent's included relation.
115
+ */
116
+ async function processNestedCreate(items, fieldName, relatedListName,
117
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
118
+ relatedListConfig, context, config, prisma, afterTasks, recovery) {
17
119
  const itemsArray = Array.isArray(items) ? items : [items];
18
- const processedItems = await Promise.all(itemsArray.map(async (item) => {
120
+ const processedItems = await Promise.all(itemsArray.map(async (item, index) => {
19
121
  // 1. Check create access (skip if sudo mode)
20
122
  if (!context._isSudo) {
21
123
  const createAccess = relatedListConfig.access?.operation?.create;
@@ -27,15 +129,7 @@ relatedListConfig, context, config) {
27
129
  throw new Error('Access denied: Cannot create related item');
28
130
  }
29
131
  }
30
- // 2. Get the list name for this related config
31
- let relatedListName = '';
32
- for (const [listKey, listCfg] of Object.entries(config.lists)) {
33
- if (listCfg === relatedListConfig) {
34
- relatedListName = listKey;
35
- break;
36
- }
37
- }
38
- // 3. Execute list-level resolveInput hook
132
+ // 2. Execute list-level resolveInput hook
39
133
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
40
134
  listKey: relatedListName,
41
135
  operation: 'create',
@@ -44,9 +138,9 @@ relatedListConfig, context, config) {
44
138
  item: undefined,
45
139
  context,
46
140
  });
47
- // 4. Execute field-level resolveInput hooks
141
+ // 3. Execute field-level resolveInput hooks
48
142
  resolvedData = await executeFieldResolveInputHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
49
- // 5. Execute validate hook
143
+ // 4. Execute validate hook
50
144
  await executeValidate(relatedListConfig.hooks, {
51
145
  listKey: relatedListName,
52
146
  operation: 'create',
@@ -55,96 +149,203 @@ relatedListConfig, context, config) {
55
149
  item: undefined,
56
150
  context,
57
151
  });
58
- // 4. Field validation
152
+ // 4.5 Field-level validate hooks
153
+ await executeFieldValidateHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
154
+ // 5. Field validation (built-in rules)
59
155
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'create');
60
156
  if (validation.errors.length > 0) {
61
157
  throw new ValidationError(validation.errors, validation.fieldErrors);
62
158
  }
63
- // 5. Filter writable fields
159
+ // 6. Filter writable fields
64
160
  const filtered = await filterWritableFields(resolvedData, relatedListConfig.fields, 'create', {
65
161
  session: context.session,
66
162
  context,
67
163
  inputData: item,
68
164
  });
69
- // 6. Recursively process nested operations in this item
70
- return await processNestedOperations(filtered, relatedListConfig.fields, config, context, 'create');
165
+ // 7. Recursively process nested operations in this item. This nested row
166
+ // is itself being CREATED, so its own relations have no pre-existing rows
167
+ // (parent originalItem is undefined → empty pre-existing set).
168
+ const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(filtered, relatedListConfig.fields, config, { ...context, prisma }, 'create', relatedListName, undefined,
169
+ // This nested row is being CREATED, so its enclosing inputData is its own
170
+ // create payload (passed to the connect-site owning-field gate, #588).
171
+ item);
172
+ // 8. Field-level beforeOperation (side effects) for this nested create
173
+ await executeFieldBeforeOperationHooks(item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
174
+ // 9. List-level beforeOperation for this nested create
175
+ await executeBeforeOperation(relatedListConfig.hooks, {
176
+ listKey: relatedListName,
177
+ operation: 'create',
178
+ inputData: item,
179
+ resolvedData,
180
+ context,
181
+ });
182
+ // 10. Register afterOperation: fires once the parent (and thus this nested
183
+ // row) has persisted. The created row is recovered by id-diff and paired
184
+ // to THIS create-payload entry by position (see CreatedRowRecovery), so a
185
+ // to-many `create: [{A},{B}]` fires once per row, each against its OWN
186
+ // distinct row, and never against a pre-existing sibling.
187
+ afterTasks.push({
188
+ fieldName,
189
+ run: async (parentResult) => {
190
+ const createdItem = recovery.rowAt(parentResult, index);
191
+ if (!createdItem) {
192
+ // The created row could not be identified by id-diff — the parent
193
+ // write did not return this nested relation (e.g. the underlying
194
+ // client does not echo `include`d relations). We must NOT hand an
195
+ // id-less `{}` to a hook as if it were the persisted row (finding 4:
196
+ // that would fire `afterOperation` against a fabricated item). The
197
+ // before-persist hooks have already run; we deliberately SKIP this
198
+ // record's create `afterOperation` rather than fire it with a bogus
199
+ // item. Real Prisma always echoes the `include`d relation, so this
200
+ // skip is reached only by clients/mocks that omit it. `item`
201
+ // correctness is the must-have; a missing row is never fabricated.
202
+ return;
203
+ }
204
+ await executeAfterOperation(relatedListConfig.hooks, {
205
+ listKey: relatedListName,
206
+ operation: 'create',
207
+ inputData: item,
208
+ item: createdItem,
209
+ resolvedData,
210
+ context,
211
+ });
212
+ await executeFieldAfterOperationHooks(createdItem, item, resolvedData, relatedListConfig.fields, 'create', context, relatedListName);
213
+ // Run any deeper nested afterOperation tasks, scoped to the persisted row.
214
+ await runAfterTasks(childAfterTasks, createdItem);
215
+ },
216
+ });
217
+ return nestedData;
71
218
  }));
72
219
  return Array.isArray(items) ? processedItems : processedItems[0];
73
220
  }
74
221
  /**
75
- * Process nested connect operations
76
- * Verifies update access to the items being connected
222
+ * Verify that a single connection target is reachable for the caller.
223
+ *
224
+ * Connecting an existing row references it; it does not modify the row's own
225
+ * data. Mirroring Keystone, this requires **read/query** access on the target
226
+ * list (not `update`). When query access returns a filter object, the filter is
227
+ * evaluated in the DATABASE (not in memory) via
228
+ * `findFirst({ where: { AND: [connection, accessFilter] } })`. The connect is
229
+ * allowed iff that query returns a row, which correctly handles arbitrary
230
+ * nested-relation predicates and boolean combinators (`AND`/`OR`/`some`/
231
+ * `none`/`not`). The existence check is folded into the reachability query so a
232
+ * non-existent id is still denied.
233
+ *
234
+ * In ADDITION to the target read/reachability check (#578), the OWNING
235
+ * relationship field's field-level access (its `create`/`update` access on the
236
+ * list being written, e.g. `Post.author`) must permit the connect (#588). This
237
+ * is the other half Keystone required: a connect needs read access on the
238
+ * target AND write access on the owning relationship field. If the owning
239
+ * field's field-level access denies, the connect is denied even when the target
240
+ * row is readable/reachable.
241
+ *
242
+ * Sudo bypasses the entire check (handled by the caller).
243
+ */
244
+ async function verifyConnectReachable(connection, relatedListName,
245
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
246
+ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData) {
247
+ // Access Prisma model dynamically - required because model names are generated at runtime
248
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
249
+ const model = prisma[getDbKey(relatedListName)];
250
+ // #588 — gate the connect by the OWNING relationship field's field-level
251
+ // access (evaluated for the enclosing write's operation). This runs in
252
+ // addition to the target read/reachability check below; a deny here denies
253
+ // the connect even if the target row is readable. `checkFieldAccess` returns
254
+ // `true` under sudo, but the caller already skips this whole function for
255
+ // sudo, so the gate never fires for trusted writes.
256
+ //
257
+ // `item`/`inputData` are the ENCLOSING write's `originalItem`/`inputData` —
258
+ // the SAME values the canonical Phase-5 `filterWritableFields` call passes for
259
+ // this field — so a field-access rule that depends on `item` or `inputData`
260
+ // (e.g. `({ item }) => item.status === 'draft'`) evaluates identically here and
261
+ // at Phase 5, and the two gates cannot diverge into a spurious connect denial.
262
+ const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
263
+ session: context.session,
264
+ item: enclosingItem,
265
+ inputData: enclosingInputData,
266
+ context,
267
+ });
268
+ if (!owningFieldAllowed) {
269
+ throw new Error('Access denied: Cannot connect to this item');
270
+ }
271
+ // Connecting references an existing row; it requires READ (query) access on
272
+ // the target, not update access.
273
+ const queryAccess = relatedListConfig.access?.operation?.query;
274
+ const accessResult = await checkAccess(queryAccess, {
275
+ session: context.session,
276
+ context,
277
+ });
278
+ // Explicit denial.
279
+ if (accessResult === false) {
280
+ throw new Error('Access denied: Cannot connect to this item');
281
+ }
282
+ // Full access: still verify the row exists (keep "Item not found" behaviour).
283
+ if (accessResult === true) {
284
+ const item = await model.findUnique({ where: connection });
285
+ if (!item) {
286
+ throw new Error(`Cannot connect: Item not found`);
287
+ }
288
+ return;
289
+ }
290
+ // Filter result: confirm the row is reachable under the access filter by
291
+ // AND-combining the connection identifier with the filter and querying the DB.
292
+ // A non-existent id and an unreachable row both yield no row → denied. This
293
+ // correctly evaluates arbitrary nested-relation predicates and boolean
294
+ // combinators because the database does the matching, not an in-memory walk.
295
+ const reachable = await model.findFirst({
296
+ where: { AND: [connection, accessResult] },
297
+ });
298
+ if (!reachable) {
299
+ throw new Error('Access denied: Cannot connect to this item');
300
+ }
301
+ }
302
+ /**
303
+ * Process nested connect operations.
304
+ * Verifies read (query) access to the items being connected via DB reachability
305
+ * AND the owning relationship field's field-level access (#588).
77
306
  */
78
307
  async function processNestedConnect(connections, relatedListName,
79
308
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
80
- relatedListConfig, context, prisma) {
309
+ relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData) {
81
310
  const connectionsArray = Array.isArray(connections) ? connections : [connections];
82
- // Check update access for each item being connected (skip if sudo mode)
311
+ // Check read access for each item being connected (skip if sudo mode)
83
312
  if (!context._isSudo) {
84
313
  for (const connection of connectionsArray) {
85
- // Access Prisma model dynamically - required because model names are generated at runtime
86
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
- const model = prisma[getDbKey(relatedListName)];
88
- // Fetch the item to check access
89
- const item = await model.findUnique({
90
- where: connection,
91
- });
92
- if (!item) {
93
- throw new Error(`Cannot connect: Item not found`);
94
- }
95
- // Check update access (connecting modifies the relationship)
96
- const updateAccess = relatedListConfig.access?.operation?.update;
97
- const accessResult = await checkAccess(updateAccess, {
98
- session: context.session,
99
- item,
100
- context,
101
- });
102
- if (accessResult === false) {
103
- throw new Error('Access denied: Cannot connect to this item');
104
- }
105
- // If access returns a filter, check if item matches
106
- if (typeof accessResult === 'object') {
107
- // Simple field matching
108
- for (const [key, value] of Object.entries(accessResult)) {
109
- if (typeof value === 'object' && value !== null && 'equals' in value) {
110
- if (item[key] !== value.equals) {
111
- throw new Error('Access denied: Cannot connect to this item');
112
- }
113
- }
114
- else if (item[key] !== value) {
115
- throw new Error('Access denied: Cannot connect to this item');
116
- }
117
- }
118
- }
314
+ await verifyConnectReachable(connection, relatedListName, relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData);
119
315
  }
120
316
  }
121
317
  return connections;
122
318
  }
123
319
  /**
124
- * Process nested update operations
125
- * Applies hooks and access control to updates
320
+ * Process nested update operations.
321
+ *
322
+ * Runs the target list's full update input pipeline AND its `beforeOperation`,
323
+ * then registers an `afterOperation` task receiving `originalItem` (the row
324
+ * fetched before the write) and the persisted updated `item`.
126
325
  */
127
- async function processNestedUpdate(updates, relatedListName,
326
+ async function processNestedUpdate(updates, fieldName, relatedListName,
128
327
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
129
- relatedListConfig, context, config, prisma) {
328
+ relatedListConfig, context, config, prisma, afterTasks) {
130
329
  const updatesArray = Array.isArray(updates) ? updates : [updates];
131
330
  const processedUpdates = await Promise.all(updatesArray.map(async (update) => {
132
331
  // Access Prisma model dynamically - required because model names are generated at runtime
133
332
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
333
  const model = prisma[getDbKey(relatedListName)];
135
- // Fetch the existing item
136
- const item = await model.findUnique({
137
- where: update.where,
138
- });
139
- if (!item) {
334
+ const where = update.where;
335
+ // Fetch the existing item reused as `originalItem` for afterOperation.
336
+ const originalItem = await model.findUnique({ where });
337
+ if (!originalItem) {
140
338
  throw new Error('Cannot update: Item not found');
141
339
  }
340
+ // The updated row's id is known up front, so the included-result read-back
341
+ // finds this row directly by id.
342
+ const knownId = typeof originalItem.id === 'string' ? originalItem.id : undefined;
142
343
  // Check update access (skip if sudo mode)
143
344
  if (!context._isSudo) {
144
345
  const updateAccess = relatedListConfig.access?.operation?.update;
145
346
  const accessResult = await checkAccess(updateAccess, {
146
347
  session: context.session,
147
- item,
348
+ item: originalItem,
148
349
  context,
149
350
  });
150
351
  if (accessResult === false) {
@@ -158,21 +359,23 @@ relatedListConfig, context, config, prisma) {
158
359
  operation: 'update',
159
360
  inputData: updateData,
160
361
  resolvedData: updateData,
161
- item,
362
+ item: originalItem,
162
363
  context,
163
364
  });
164
365
  // Execute field-level resolveInput hooks
165
- resolvedData = await executeFieldResolveInputHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, item);
366
+ resolvedData = await executeFieldResolveInputHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
166
367
  // Execute validate hook
167
368
  await executeValidate(relatedListConfig.hooks, {
168
369
  listKey: relatedListName,
169
370
  operation: 'update',
170
371
  inputData: updateData,
171
372
  resolvedData,
172
- item,
373
+ item: originalItem,
173
374
  context,
174
375
  });
175
- // Field validation
376
+ // Field-level validate hooks
377
+ await executeFieldValidateHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
378
+ // Field validation (built-in rules)
176
379
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'update');
177
380
  if (validation.errors.length > 0) {
178
381
  throw new ValidationError(validation.errors, validation.fieldErrors);
@@ -180,97 +383,263 @@ relatedListConfig, context, config, prisma) {
180
383
  // Filter writable fields
181
384
  const filtered = await filterWritableFields(resolvedData, relatedListConfig.fields, 'update', {
182
385
  session: context.session,
183
- item,
386
+ item: originalItem,
184
387
  context,
185
388
  inputData: updateData,
186
389
  });
187
- // Recursively process nested operations
188
- const processedData = await processNestedOperations(filtered, relatedListConfig.fields, config, context, 'update');
390
+ // Recursively process nested operations. This nested row is being UPDATED,
391
+ // so its own relations' pre-existing rows are captured from `originalItem`.
392
+ const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(filtered, relatedListConfig.fields, config, { ...context, prisma }, 'update', relatedListName, originalItem,
393
+ // This nested row is being UPDATED, so its enclosing inputData is its own
394
+ // update payload (passed to the connect-site owning-field gate, #588).
395
+ updateData);
396
+ // Field-level beforeOperation (side effects)
397
+ await executeFieldBeforeOperationHooks(updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
398
+ // List-level beforeOperation
399
+ await executeBeforeOperation(relatedListConfig.hooks, {
400
+ listKey: relatedListName,
401
+ operation: 'update',
402
+ inputData: updateData,
403
+ item: originalItem,
404
+ resolvedData,
405
+ context,
406
+ });
407
+ // Register afterOperation: fires after the parent persist. The updated row
408
+ // is recovered from the parent's included relation by its known id.
409
+ afterTasks.push({
410
+ fieldName,
411
+ run: async (parentResult) => {
412
+ const persisted = recoverUpdatedRow(parentResult, fieldName, knownId);
413
+ const updatedItem = persisted ?? originalItem;
414
+ await executeAfterOperation(relatedListConfig.hooks, {
415
+ listKey: relatedListName,
416
+ operation: 'update',
417
+ inputData: updateData,
418
+ originalItem,
419
+ item: updatedItem,
420
+ resolvedData,
421
+ context,
422
+ });
423
+ await executeFieldAfterOperationHooks(updatedItem, updateData, resolvedData, relatedListConfig.fields, 'update', context, relatedListName, originalItem);
424
+ await runAfterTasks(childAfterTasks, updatedItem);
425
+ },
426
+ });
189
427
  return {
190
- where: update.where,
191
- data: processedData,
428
+ where,
429
+ data: nestedData,
192
430
  };
193
431
  }));
194
432
  return Array.isArray(updates) ? processedUpdates : processedUpdates[0];
195
433
  }
434
+ /**
435
+ * Process nested delete operations.
436
+ *
437
+ * Runs the target list's delete pipeline (validate/field-validate +
438
+ * `beforeOperation`) before the parent persist, and registers an
439
+ * `afterOperation` task receiving the `originalItem` (the row before deletion).
440
+ * Persistence is performed by Prisma's nested write; the row no longer exists
441
+ * after, so `originalItem` is the authoritative record for after-hooks.
442
+ */
443
+ async function processNestedDelete(deletes, relatedListName,
444
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
445
+ relatedListConfig, context, prisma, afterTasks) {
446
+ // A to-one relation delete can be a boolean (`{ delete: true }`); there is no
447
+ // identifying `where`, so we cannot run target-resolved hooks. Pass through.
448
+ if (typeof deletes === 'boolean') {
449
+ return deletes;
450
+ }
451
+ const deletesArray = Array.isArray(deletes) ? deletes : [deletes];
452
+ await Promise.all(deletesArray.map(async (del) => {
453
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
454
+ const model = prisma[getDbKey(relatedListName)];
455
+ // A nested delete entry is itself the unique `where` (e.g. `{ id }`).
456
+ const where = del;
457
+ const originalItem = await model.findUnique({ where });
458
+ if (!originalItem) {
459
+ throw new Error('Cannot delete: Item not found');
460
+ }
461
+ // Check delete access (skip if sudo mode)
462
+ if (!context._isSudo) {
463
+ const deleteAccess = relatedListConfig.access?.operation?.delete;
464
+ const accessResult = await checkAccess(deleteAccess, {
465
+ session: context.session,
466
+ item: originalItem,
467
+ context,
468
+ });
469
+ if (accessResult === false) {
470
+ throw new Error('Access denied: Cannot delete related item');
471
+ }
472
+ }
473
+ // List-level validate (delete)
474
+ await executeValidate(relatedListConfig.hooks, {
475
+ listKey: relatedListName,
476
+ operation: 'delete',
477
+ item: originalItem,
478
+ context,
479
+ });
480
+ // Field-level validate (delete)
481
+ await executeFieldValidateHooks(undefined, undefined, relatedListConfig.fields, 'delete', context, relatedListName, originalItem);
482
+ // Field-level beforeOperation (delete)
483
+ await executeFieldBeforeOperationHooks({}, {}, relatedListConfig.fields, 'delete', context, relatedListName, originalItem);
484
+ // List-level beforeOperation (delete)
485
+ await executeBeforeOperation(relatedListConfig.hooks, {
486
+ listKey: relatedListName,
487
+ operation: 'delete',
488
+ item: originalItem,
489
+ context,
490
+ });
491
+ // Register afterOperation: the row is gone after persist, so the
492
+ // originalItem is the authoritative record passed to after-hooks.
493
+ afterTasks.push({
494
+ fieldName: '',
495
+ run: async () => {
496
+ await executeAfterOperation(relatedListConfig.hooks, {
497
+ listKey: relatedListName,
498
+ operation: 'delete',
499
+ originalItem,
500
+ context,
501
+ });
502
+ await executeFieldAfterOperationHooks(originalItem, undefined, undefined, relatedListConfig.fields, 'delete', context, relatedListName, originalItem);
503
+ },
504
+ });
505
+ }));
506
+ return deletes;
507
+ }
196
508
  /**
197
509
  * Process nested connectOrCreate operations
198
510
  */
199
- async function processNestedConnectOrCreate(operations, relatedListName,
511
+ async function processNestedConnectOrCreate(operations, fieldName, relatedListName,
200
512
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
201
- relatedListConfig, context, config, prisma) {
513
+ relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData) {
202
514
  const operationsArray = Array.isArray(operations) ? operations : [operations];
203
515
  const processedOps = await Promise.all(operationsArray.map(async (op) => {
204
- // Process the create portion through create hooks
205
516
  const opRecord = op;
206
- const processedCreate = await processNestedCreate(opRecord.create, relatedListConfig, context, config);
207
- // Check access for the connect portion (try to find existing item) (skip if sudo mode)
517
+ // Check access for the connect portion (skip if sudo mode).
518
+ //
519
+ // connectOrCreate connects an existing row when present, otherwise
520
+ // creates. So when the row exists we apply the same connect semantics as
521
+ // processNestedConnect — READ (query) access on the target, evaluated via
522
+ // DB reachability for filter results, PLUS the owning relationship field's
523
+ // field-level access (#588). When the row does not exist we fall through to
524
+ // create. We must NOT swallow an access-denied error: only the genuine
525
+ // "row absent" case may fall back to create.
526
+ let rowExists = false;
208
527
  if (!context._isSudo) {
209
- try {
210
- // Access Prisma model dynamically - required because model names are generated at runtime
211
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- const model = prisma[getDbKey(relatedListName)];
213
- const existingItem = await model.findUnique({
214
- where: opRecord.where,
528
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
529
+ const model = prisma[getDbKey(relatedListName)];
530
+ const where = opRecord.where;
531
+ const existingItem = await model.findUnique({ where });
532
+ // Only enforce connect access when the row actually exists; otherwise
533
+ // the create branch is used.
534
+ if (existingItem) {
535
+ rowExists = true;
536
+ // #588 — gate the connect branch by the OWNING relationship field's
537
+ // field-level access, identical to processNestedConnect. A deny here
538
+ // denies the connect even if the target row is readable/reachable.
539
+ // `item`/`inputData` are the ENCLOSING write's `originalItem`/
540
+ // `inputData` (the same values Phase-5 `filterWritableFields` passes),
541
+ // so item-/inputData-dependent field rules cannot diverge between the
542
+ // two gates.
543
+ const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
544
+ session: context.session,
545
+ item: enclosingItem,
546
+ inputData: enclosingInputData,
547
+ context,
215
548
  });
216
- if (existingItem) {
217
- // Check update access for connection
218
- const updateAccess = relatedListConfig.access?.operation?.update;
219
- const accessResult = await checkAccess(updateAccess, {
220
- session: context.session,
221
- item: existingItem,
222
- context,
549
+ if (!owningFieldAllowed) {
550
+ throw new Error('Access denied: Cannot connect to existing item');
551
+ }
552
+ const queryAccess = relatedListConfig.access?.operation?.query;
553
+ const accessResult = await checkAccess(queryAccess, {
554
+ session: context.session,
555
+ item: existingItem,
556
+ context,
557
+ });
558
+ if (accessResult === false) {
559
+ throw new Error('Access denied: Cannot connect to existing item');
560
+ }
561
+ // Filter result: confirm the existing row is reachable under the
562
+ // access filter via DB reachability (handles nested/boolean filters).
563
+ if (accessResult !== true) {
564
+ const reachable = await model.findFirst({
565
+ where: { AND: [where, accessResult] },
223
566
  });
224
- if (accessResult === false) {
567
+ if (!reachable) {
225
568
  throw new Error('Access denied: Cannot connect to existing item');
226
569
  }
227
570
  }
228
571
  }
229
- catch {
230
- // Item doesn't exist, will use create (already processed)
231
- }
232
572
  }
573
+ // Process the create portion through the full create pipeline (incl.
574
+ // before/afterOperation). Only register an afterOperation task when the
575
+ // create branch will actually run (row absent), so a pure connect does not
576
+ // fire create hooks. Under sudo we cannot statically know, so we let the
577
+ // create pipeline run its hooks (sudo bypasses access only, not hooks).
578
+ const runCreateHooks = context._isSudo || !rowExists;
579
+ const createAfterTasks = runCreateHooks ? afterTasks : [];
580
+ const processedCreate = await processNestedCreate(opRecord.create, fieldName, relatedListName, relatedListConfig, context, config, prisma, createAfterTasks, recovery);
233
581
  return {
234
- where: op.where,
582
+ where: opRecord.where,
235
583
  create: processedCreate,
236
584
  };
237
585
  }));
238
586
  return Array.isArray(operations) ? processedOps : processedOps[0];
239
587
  }
588
+ /**
589
+ * Narrow the lazily-built {@link CreatedRowRecovery} to a present value for the
590
+ * created kinds (`create`, `connectOrCreate`). It is always provided for these
591
+ * kinds by {@link processFieldNestedOps}; the guard backstops a programming
592
+ * error rather than a user-facing path.
593
+ */
594
+ function requireRecovery(recovery, kind) {
595
+ if (!recovery) {
596
+ throw new Error(`Internal error: missing created-row recovery for nested "${kind}"`);
597
+ }
598
+ return recovery;
599
+ }
600
+ /** Nested-op kinds that can create new rows and so need created-row recovery. */
601
+ const CREATING_KINDS = new Set(['create', 'connectOrCreate']);
240
602
  /**
241
603
  * Registry of nested-operation handlers keyed by nested-op kind.
242
604
  *
243
- * The dispatch loop in {@link processNestedOperations} looks handlers up here
244
- * instead of branching on each kind. Kinds that require hooks/access control
245
- * (`create`, `connect`, `connectOrCreate`, `update`) provide an `execute` that
246
- * applies them; pass-through kinds (`disconnect`, `delete`, `deleteMany`,
247
- * `set`, `updateMany`) return their value unchanged so Prisma's own
248
- * constraints apply.
605
+ * Kinds that run the full hook pipeline (`create`, `update`, `delete`, and the
606
+ * create branch of `connectOrCreate`) run `beforeOperation` inline and register
607
+ * deferred `afterOperation` tasks. `connect`/`connectOrCreate`'s connect branch
608
+ * enforce access only. Remaining pass-through kinds (`disconnect`, `set`,
609
+ * `updateMany`, `deleteMany`) return their value unchanged so Prisma's own
610
+ * constraints apply — they are intentionally NOT in scope for #569.
249
611
  */
250
612
  const nestedOpRegistry = {
251
613
  create: {
252
- execute: ({ value, relatedListConfig, context, config }) => processNestedCreate(value, relatedListConfig, context, config),
614
+ needsInclude: true,
615
+ execute: ({ value, fieldName, relatedListName, relatedListConfig, context, config, prisma, afterTasks, recovery, }) => processNestedCreate(value, fieldName, relatedListName, relatedListConfig, context, config, prisma, afterTasks, requireRecovery(recovery, 'create')),
253
616
  },
254
617
  connect: {
255
- execute: ({ value, relatedListName, relatedListConfig, context, prisma }) => processNestedConnect(value, relatedListName, relatedListConfig, context, prisma),
618
+ needsInclude: false,
619
+ execute: ({ value, relatedListName, relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData, }) => processNestedConnect(value, relatedListName, relatedListConfig, context, prisma, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData),
256
620
  },
257
621
  connectOrCreate: {
258
- execute: ({ value, relatedListName, relatedListConfig, context, config, prisma }) => processNestedConnectOrCreate(value, relatedListName, relatedListConfig, context, config, prisma),
622
+ needsInclude: true,
623
+ execute: ({ value, fieldName, relatedListName, relatedListConfig, context, config, prisma, afterTasks, recovery, owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData, }) => processNestedConnectOrCreate(value, fieldName, relatedListName, relatedListConfig, context, config, prisma, afterTasks, requireRecovery(recovery, 'connectOrCreate'), owningFieldAccess, enclosingOperation, enclosingItem, enclosingInputData),
259
624
  },
260
625
  update: {
261
- execute: ({ value, relatedListName, relatedListConfig, context, config, prisma }) => processNestedUpdate(value, relatedListName, relatedListConfig, context, config, prisma),
626
+ needsInclude: true,
627
+ execute: ({ value, fieldName, relatedListName, relatedListConfig, context, config, prisma, afterTasks, }) => processNestedUpdate(value, fieldName, relatedListName, relatedListConfig, context, config, prisma, afterTasks),
628
+ },
629
+ delete: {
630
+ // The row no longer exists after the parent write, so no read-back include.
631
+ needsInclude: false,
632
+ execute: ({ value, relatedListName, relatedListConfig, context, prisma, afterTasks }) => processNestedDelete(value, relatedListName, relatedListConfig, context, prisma, afterTasks),
262
633
  },
263
634
  // Pass-through kinds: no hooks/access control, left to Prisma's own constraints.
264
- disconnect: { execute: ({ value }) => Promise.resolve(value) },
265
- delete: { execute: ({ value }) => Promise.resolve(value) },
266
- deleteMany: { execute: ({ value }) => Promise.resolve(value) },
267
- set: { execute: ({ value }) => Promise.resolve(value) },
268
- updateMany: { execute: ({ value }) => Promise.resolve(value) },
635
+ // (Out of scope for #569 see the issue's "Out of scope" notes.)
636
+ disconnect: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
637
+ deleteMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
638
+ set: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
639
+ updateMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
269
640
  };
270
641
  /**
271
642
  * Order in which nested-op kinds are processed for a single relationship field.
272
- *
273
- * Mirrors the historical in-place dispatch order so behaviour is preserved.
274
643
  */
275
644
  const nestedOpOrder = [
276
645
  'create',
@@ -288,26 +657,55 @@ const nestedOpOrder = [
288
657
  * relationship field's value, dispatching each present nested-op kind through
289
658
  * the {@link nestedOpRegistry}.
290
659
  */
291
- async function processFieldNestedOps(valueRecord, args) {
660
+ async function processFieldNestedOps(fieldName, valueRecord, args, includeFields, parentListName, parentOriginalItem) {
292
661
  const nestedOp = {};
662
+ // Created-row recovery is only needed when this field has a creating kind
663
+ // (`create`/`connectOrCreate`). When present it requires a pre-persist read of
664
+ // the parent's current related ids, so build it once, lazily, and share it
665
+ // across the creating kinds on this field.
666
+ let recovery;
667
+ const hasCreatingKind = nestedOpOrder.some((kind) => CREATING_KINDS.has(kind) && valueRecord[kind] !== undefined);
668
+ if (hasCreatingKind) {
669
+ const preExistingIds = await capturePreExistingIds(parentListName, parentOriginalItem, fieldName, args.prisma);
670
+ recovery = createCreatedRowRecovery(fieldName, preExistingIds);
671
+ }
293
672
  for (const kind of nestedOpOrder) {
294
673
  const value = valueRecord[kind];
295
674
  if (value === undefined) {
296
675
  continue;
297
676
  }
298
677
  const handler = nestedOpRegistry[kind];
299
- nestedOp[kind] = await handler.execute({ ...args, value });
678
+ if (handler.needsInclude) {
679
+ includeFields.add(fieldName);
680
+ }
681
+ nestedOp[kind] = await handler.execute({
682
+ ...args,
683
+ value,
684
+ fieldName,
685
+ recovery,
686
+ });
300
687
  }
301
688
  return nestedOp;
302
689
  }
303
690
  /**
304
- * Process all nested operations in a data payload
305
- * Recursively handles relationship fields with nested writes
691
+ * Process all nested operations in a data payload.
692
+ *
693
+ * Recursively handles relationship fields with nested writes. In addition to
694
+ * transforming the payload it runs each nested record's `beforeOperation` and
695
+ * collects deferred `afterOperation` tasks (run by the Write Pipeline after the
696
+ * parent persist via {@link runAfterTasks}). See ADR-0010.
306
697
  */
307
- export async function processNestedOperations(data, fieldConfigs, config, context, operation, depth = 0) {
698
+ export async function processNestedOperations(data, fieldConfigs, config, context, operation, parentListName, parentOriginalItem,
699
+ // The enclosing write's input data (the SAME value Phase-5 `filterWritableFields`
700
+ // passes as `inputData`). Threaded into the connect-site owning-field gate (#588
701
+ // finding) so item-/inputData-dependent field-access rules cannot diverge between
702
+ // Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
703
+ parentInputData = undefined, depth = 0) {
308
704
  const MAX_DEPTH = 5;
705
+ const afterTasks = [];
706
+ const includeFields = new Set();
309
707
  if (depth >= MAX_DEPTH) {
310
- return data;
708
+ return { data, afterTasks, includeFields };
311
709
  }
312
710
  const processed = {};
313
711
  for (const [fieldName, value] of Object.entries(data)) {
@@ -325,15 +723,40 @@ export async function processNestedOperations(data, fieldConfigs, config, contex
325
723
  continue;
326
724
  }
327
725
  const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig;
328
- // Dispatch each present nested-op kind through the handler registry.
329
- processed[fieldName] = await processFieldNestedOps(value, {
330
- relatedListName,
726
+ // Sanity: ensure the resolved list name matches the config identity.
727
+ const resolvedListName = relatedListName || findListName(relatedListConfig, config);
728
+ // #588 — the owning relationship field's field-level access (e.g. the
729
+ // `access` on `Post.author`). Threaded into the nested-op handlers so the
730
+ // connect/connectOrCreate handlers can gate connects by this field's
731
+ // create/update access, in addition to the target's read access.
732
+ const owningFieldAccess = fieldConfig.access;
733
+ processed[fieldName] = await processFieldNestedOps(fieldName, value, {
734
+ relatedListName: resolvedListName,
331
735
  relatedListConfig,
736
+ owningFieldAccess,
737
+ enclosingOperation: operation,
738
+ // The enclosing write's `originalItem`/`inputData` — the SAME values the
739
+ // canonical Phase-5 `filterWritableFields` call passes for this field — so
740
+ // the connect-site owning-field gate evaluates item-/inputData-dependent
741
+ // rules identically and cannot diverge into a spurious connect denial (#588).
742
+ enclosingItem: parentOriginalItem,
743
+ enclosingInputData: parentInputData ?? {},
332
744
  context,
333
745
  config,
334
746
  prisma: context.prisma,
335
- });
747
+ afterTasks,
748
+ }, includeFields, parentListName, parentOriginalItem);
749
+ }
750
+ return { data: processed, afterTasks, includeFields };
751
+ }
752
+ /**
753
+ * Run a set of deferred nested `afterOperation` tasks against a persisted parent
754
+ * row. Tasks run sequentially so a throwing after-hook aborts the rest (and, run
755
+ * inside the transaction by the Write Pipeline, rolls the whole write back).
756
+ */
757
+ export async function runAfterTasks(afterTasks, parentResult) {
758
+ for (const task of afterTasks) {
759
+ await task.run(parentResult);
336
760
  }
337
- return processed;
338
761
  }
339
762
  //# sourceMappingURL=nested-operations.js.map