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