@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,14 +1,70 @@
1
1
  import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
2
- import type { AccessContext } from '../access/types.js'
2
+ import type { AccessContext, FieldAccess } from '../access/types.js'
3
3
  import { checkAccess, filterWritableFields, getRelatedListConfig } from '../access/index.js'
4
+ import { checkFieldAccess } from '../access/field-access.js'
4
5
  import {
5
6
  executeResolveInput,
6
7
  executeValidate,
7
8
  executeFieldResolveInputHooks,
9
+ executeBeforeOperation,
10
+ executeAfterOperation,
11
+ executeFieldBeforeOperationHooks,
12
+ executeFieldAfterOperationHooks,
13
+ executeFieldValidateHooks,
8
14
  validateFieldRules,
9
15
  ValidationError,
10
16
  } from '../hooks/index.js'
11
17
  import { getDbKey } from '../lib/case-utils.js'
18
+ import { applyCreateDefaults } from './apply-defaults.js'
19
+
20
+ /**
21
+ * Nested writes (#569 / ADR-0010).
22
+ *
23
+ * Nested `create`/`update`/`delete` must fire the SAME list- and field-level
24
+ * `beforeOperation`/`afterOperation` as the equivalent top-level write, so a
25
+ * record's side effects are identical whether it was written nested or
26
+ * top-level. Persistence itself is still performed by Prisma's single nested
27
+ * write (so Prisma keeps owning FK ordering and intra-statement atomicity); we
28
+ * run the nested records' `beforeOperation` BEFORE that persist and their
29
+ * `afterOperation` AFTER it, all inside the one interactive transaction the
30
+ * Write Pipeline opens.
31
+ *
32
+ * Mechanism (per ADR-0010, "hooks around a single nested persist"):
33
+ * - `processNestedOperations` runs nested resolveInput/validate/field-rules
34
+ * (as before) AND nested `beforeOperation`, and returns the transformed
35
+ * payload together with a list of deferred {@link AfterTask}s.
36
+ * - The Write Pipeline persists the parent (with the nested relations
37
+ * `include`d so the persisted nested rows come back), then calls
38
+ * {@link runAfterTasks} so each nested record's `afterOperation` fires with
39
+ * a real persisted `item` and (for update/delete) its `originalItem`.
40
+ * - Everything runs inside the transaction, so a throwing `beforeOperation`/
41
+ * `afterOperation` rolls back the whole write.
42
+ */
43
+
44
+ /**
45
+ * A deferred nested `afterOperation` task, run after the parent has persisted.
46
+ * It receives the persisted parent row (with nested relations included) so it
47
+ * can recover the persisted nested `item`.
48
+ */
49
+ export interface AfterTask {
50
+ /** Field name on the parent linking to the related list (for include lookup). */
51
+ fieldName: string
52
+ run(parentResult: Record<string, unknown>): Promise<void>
53
+ }
54
+
55
+ /**
56
+ * Result of processing nested operations: the transformed write payload plus
57
+ * the deferred `afterOperation` tasks and the relation fields the parent write
58
+ * must `include` so those tasks can recover their persisted `item`.
59
+ */
60
+ export interface NestedOpsResult {
61
+ /** The transformed write payload handed to Prisma. */
62
+ data: Record<string, unknown>
63
+ /** Deferred `afterOperation` tasks to run after the parent persist. */
64
+ afterTasks: AfterTask[]
65
+ /** Relationship field names to `include` in the parent write result. */
66
+ includeFields: Set<string>
67
+ }
12
68
 
13
69
  /**
14
70
  * Check if a field config is a relationship field
@@ -18,20 +74,173 @@ function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
18
74
  }
19
75
 
20
76
  /**
21
- * Process nested create operations
22
- * Applies hooks and access control to each item being created
77
+ * Resolve the related list name for a related list config (config object identity).
78
+ */
79
+ function findListName(
80
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
81
+ relatedListConfig: ListConfig<any>,
82
+ config: OpenSaasConfig,
83
+ ): string {
84
+ for (const [listKey, listCfg] of Object.entries(config.lists)) {
85
+ if (listCfg === relatedListConfig) {
86
+ return listKey
87
+ }
88
+ }
89
+ return ''
90
+ }
91
+
92
+ /**
93
+ * Read the rows of a parent's included relation as an array.
94
+ *
95
+ * A to-one relation comes back as a single row (or `null`); a to-many relation
96
+ * comes back as an array. This normalises both to an array so callers can apply
97
+ * a uniform id-diff.
98
+ */
99
+ function includedRows(
100
+ parentResult: Record<string, unknown>,
101
+ fieldName: string,
102
+ ): Array<Record<string, unknown>> {
103
+ const included = parentResult[fieldName]
104
+ if (included == null) return []
105
+ if (Array.isArray(included)) return included as Array<Record<string, unknown>>
106
+ return [included as Record<string, unknown>]
107
+ }
108
+
109
+ /**
110
+ * Recover an UPDATED nested row from the parent result by its known id.
111
+ *
112
+ * The updated row's id is known up front (it was fetched for access as
113
+ * `originalItem`), so the persisted row is the included row with that id.
114
+ */
115
+ function recoverUpdatedRow(
116
+ parentResult: Record<string, unknown>,
117
+ fieldName: string,
118
+ knownId: string | undefined,
119
+ ): Record<string, unknown> | undefined {
120
+ if (knownId === undefined) return undefined
121
+ return includedRows(parentResult, fieldName).find((r) => r.id === knownId)
122
+ }
123
+
124
+ /**
125
+ * Recover the CREATED nested rows from the parent result by id-diff.
126
+ *
127
+ * Created rows have no known id before the write, so they are identified as the
128
+ * included rows whose ids are NOT in `preExistingIds` (the set of related-row
129
+ * ids captured before the persist). Returned in include order, which the create
130
+ * handler pairs to its create-payload entries by position (see
131
+ * {@link CreatedRowRecovery}).
132
+ */
133
+ function recoverCreatedRows(
134
+ parentResult: Record<string, unknown>,
135
+ fieldName: string,
136
+ preExistingIds: Set<string>,
137
+ ): Array<Record<string, unknown>> {
138
+ return includedRows(parentResult, fieldName).filter(
139
+ (r) => typeof r.id === 'string' && !preExistingIds.has(r.id as string),
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Shared, memoised recovery of the rows created for ONE nested `create` payload
145
+ * on ONE relation field.
146
+ *
147
+ * A to-many `create: [{A},{B}]` produces several rows that must each fire their
148
+ * own `afterOperation` against their OWN row. We cannot tell which included row
149
+ * corresponds to which payload entry by content alone, so we identify the set of
150
+ * NEW rows by id-diff against the ids that existed before the persist, then pair
151
+ * them to the create-payload entries by POSITION (Prisma preserves create-array
152
+ * order in the included result). The id-diff is computed once per parent result
153
+ * and cached so every entry's task shares it.
154
+ *
155
+ * `inputData`↔row pairing is therefore positional and best-effort; `item`
156
+ * correctness (each task gets a genuinely-created, distinct row) is guaranteed:
157
+ * a pre-existing row can never be returned because it is excluded by the diff.
158
+ */
159
+ interface CreatedRowRecovery {
160
+ /** Recover the created row for the create-payload entry at `index`. */
161
+ rowAt(parentResult: Record<string, unknown>, index: number): Record<string, unknown> | undefined
162
+ }
163
+
164
+ function createCreatedRowRecovery(
165
+ fieldName: string,
166
+ preExistingIds: Set<string>,
167
+ ): CreatedRowRecovery {
168
+ let cache: { source: Record<string, unknown>; rows: Array<Record<string, unknown>> } | undefined
169
+ return {
170
+ rowAt(parentResult, index) {
171
+ if (!cache || cache.source !== parentResult) {
172
+ cache = {
173
+ source: parentResult,
174
+ rows: recoverCreatedRows(parentResult, fieldName, preExistingIds),
175
+ }
176
+ }
177
+ return cache.rows[index]
178
+ },
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Capture the ids of the rows currently linked to the parent via `fieldName`,
184
+ * BEFORE the parent persists. Used to identify which included rows are NEW
185
+ * (created by this write) afterwards.
186
+ *
187
+ * - For a parent CREATE there are no pre-existing related rows (the parent does
188
+ * not exist yet), so the set is empty.
189
+ * - For a parent UPDATE we read the parent row's current relation and collect
190
+ * its ids. The same `tx` client is used so the read participates in the
191
+ * transaction and sees a consistent snapshot.
192
+ */
193
+ async function capturePreExistingIds(
194
+ parentListName: string,
195
+ parentOriginalItem: Record<string, unknown> | undefined,
196
+ fieldName: string,
197
+ prisma: unknown,
198
+ ): Promise<Set<string>> {
199
+ const ids = new Set<string>()
200
+ const parentId = parentOriginalItem?.id
201
+ if (typeof parentId !== 'string') {
202
+ // Parent create (no existing row) — nothing pre-exists.
203
+ return ids
204
+ }
205
+
206
+ // Access Prisma model dynamically - required because model names are generated at runtime
207
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
208
+ const parentModel = (prisma as any)[getDbKey(parentListName)]
209
+ if (!parentModel?.findUnique) return ids
210
+
211
+ const current = await parentModel.findUnique({
212
+ where: { id: parentId },
213
+ include: { [fieldName]: true },
214
+ })
215
+ for (const row of includedRows((current ?? {}) as Record<string, unknown>, fieldName)) {
216
+ if (typeof row.id === 'string') ids.add(row.id)
217
+ }
218
+ return ids
219
+ }
220
+
221
+ /**
222
+ * Process nested create operations.
223
+ *
224
+ * Runs the target list's full input pipeline (resolveInput → validate →
225
+ * field-rules → filter-writable → recurse) AND its `beforeOperation`, then
226
+ * registers an `afterOperation` task keyed to the parent's included relation.
23
227
  */
24
228
  async function processNestedCreate(
25
229
  items: Record<string, unknown> | Array<Record<string, unknown>>,
230
+ fieldName: string,
231
+ relatedListName: string,
26
232
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
27
233
  relatedListConfig: ListConfig<any>,
28
234
  context: AccessContext,
29
235
  config: OpenSaasConfig,
236
+ prisma: unknown,
237
+ afterTasks: AfterTask[],
238
+ recovery: CreatedRowRecovery,
30
239
  ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
31
240
  const itemsArray = Array.isArray(items) ? items : [items]
32
241
 
33
242
  const processedItems = await Promise.all(
34
- itemsArray.map(async (item) => {
243
+ itemsArray.map(async (item, index) => {
35
244
  // 1. Check create access (skip if sudo mode)
36
245
  if (!context._isSudo) {
37
246
  const createAccess = relatedListConfig.access?.operation?.create
@@ -45,16 +254,7 @@ async function processNestedCreate(
45
254
  }
46
255
  }
47
256
 
48
- // 2. Get the list name for this related config
49
- let relatedListName = ''
50
- for (const [listKey, listCfg] of Object.entries(config.lists)) {
51
- if (listCfg === relatedListConfig) {
52
- relatedListName = listKey
53
- break
54
- }
55
- }
56
-
57
- // 3. Execute list-level resolveInput hook
257
+ // 2. Execute list-level resolveInput hook
58
258
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
59
259
  listKey: relatedListName,
60
260
  operation: 'create',
@@ -64,7 +264,7 @@ async function processNestedCreate(
64
264
  context,
65
265
  })
66
266
 
67
- // 4. Execute field-level resolveInput hooks
267
+ // 3. Execute field-level resolveInput hooks
68
268
  resolvedData = await executeFieldResolveInputHooks(
69
269
  item,
70
270
  resolvedData,
@@ -74,7 +274,13 @@ async function processNestedCreate(
74
274
  relatedListName,
75
275
  )
76
276
 
77
- // 5. Execute validate hook
277
+ // 3.5 Apply field defaults to omitted inputs (resolve-then-validate, #615).
278
+ // Mirrors the top-level Hook Pipeline so a nested required-with-default
279
+ // field resolves to its default before validation instead of failing
280
+ // `isRequired`. Create-only; explicit values (incl. null) are preserved.
281
+ resolvedData = applyCreateDefaults(resolvedData, relatedListConfig.fields)
282
+
283
+ // 4. Execute validate hook
78
284
  await executeValidate(relatedListConfig.hooks, {
79
285
  listKey: relatedListName,
80
286
  operation: 'create',
@@ -84,13 +290,23 @@ async function processNestedCreate(
84
290
  context,
85
291
  })
86
292
 
87
- // 4. Field validation
293
+ // 4.5 Field-level validate hooks
294
+ await executeFieldValidateHooks(
295
+ item,
296
+ resolvedData,
297
+ relatedListConfig.fields,
298
+ 'create',
299
+ context,
300
+ relatedListName,
301
+ )
302
+
303
+ // 5. Field validation (built-in rules)
88
304
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'create')
89
305
  if (validation.errors.length > 0) {
90
306
  throw new ValidationError(validation.errors, validation.fieldErrors)
91
307
  }
92
308
 
93
- // 5. Filter writable fields
309
+ // 6. Filter writable fields
94
310
  const filtered = await filterWritableFields(
95
311
  resolvedData,
96
312
  relatedListConfig.fields,
@@ -102,14 +318,89 @@ async function processNestedCreate(
102
318
  },
103
319
  )
104
320
 
105
- // 6. Recursively process nested operations in this item
106
- return await processNestedOperations(
321
+ // 7. Recursively process nested operations in this item. This nested row
322
+ // is itself being CREATED, so its own relations have no pre-existing rows
323
+ // (parent originalItem is undefined → empty pre-existing set).
324
+ const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(
107
325
  filtered,
108
326
  relatedListConfig.fields,
109
327
  config,
110
- context,
328
+ { ...context, prisma },
329
+ 'create',
330
+ relatedListName,
331
+ undefined,
332
+ // This nested row is being CREATED, so its enclosing inputData is its own
333
+ // create payload (passed to the connect-site owning-field gate, #588).
334
+ item,
335
+ )
336
+
337
+ // 8. Field-level beforeOperation (side effects) for this nested create
338
+ await executeFieldBeforeOperationHooks(
339
+ item,
340
+ resolvedData,
341
+ relatedListConfig.fields,
111
342
  'create',
343
+ context,
344
+ relatedListName,
112
345
  )
346
+
347
+ // 9. List-level beforeOperation for this nested create
348
+ await executeBeforeOperation(relatedListConfig.hooks, {
349
+ listKey: relatedListName,
350
+ operation: 'create',
351
+ inputData: item,
352
+ resolvedData,
353
+ context,
354
+ })
355
+
356
+ // 10. Register afterOperation: fires once the parent (and thus this nested
357
+ // row) has persisted. The created row is recovered by id-diff and paired
358
+ // to THIS create-payload entry by position (see CreatedRowRecovery), so a
359
+ // to-many `create: [{A},{B}]` fires once per row, each against its OWN
360
+ // distinct row, and never against a pre-existing sibling.
361
+ afterTasks.push({
362
+ fieldName,
363
+ run: async (parentResult) => {
364
+ const createdItem = recovery.rowAt(parentResult, index)
365
+ if (!createdItem) {
366
+ // The created row could not be identified by id-diff — the parent
367
+ // write did not return this nested relation (e.g. the underlying
368
+ // client does not echo `include`d relations). We must NOT hand an
369
+ // id-less `{}` to a hook as if it were the persisted row (finding 4:
370
+ // that would fire `afterOperation` against a fabricated item). The
371
+ // before-persist hooks have already run; we deliberately SKIP this
372
+ // record's create `afterOperation` rather than fire it with a bogus
373
+ // item. Real Prisma always echoes the `include`d relation, so this
374
+ // skip is reached only by clients/mocks that omit it. `item`
375
+ // correctness is the must-have; a missing row is never fabricated.
376
+ return
377
+ }
378
+
379
+ await executeAfterOperation(relatedListConfig.hooks, {
380
+ listKey: relatedListName,
381
+ operation: 'create',
382
+ inputData: item,
383
+ item: createdItem,
384
+ resolvedData,
385
+ context,
386
+ })
387
+
388
+ await executeFieldAfterOperationHooks(
389
+ createdItem,
390
+ item,
391
+ resolvedData,
392
+ relatedListConfig.fields,
393
+ 'create',
394
+ context,
395
+ relatedListName,
396
+ )
397
+
398
+ // Run any deeper nested afterOperation tasks, scoped to the persisted row.
399
+ await runAfterTasks(childAfterTasks, createdItem)
400
+ },
401
+ })
402
+
403
+ return nestedData
113
404
  }),
114
405
  )
115
406
 
@@ -117,8 +408,106 @@ async function processNestedCreate(
117
408
  }
118
409
 
119
410
  /**
120
- * Process nested connect operations
121
- * Verifies update access to the items being connected
411
+ * Verify that a single connection target is reachable for the caller.
412
+ *
413
+ * Connecting an existing row references it; it does not modify the row's own
414
+ * data. Mirroring Keystone, this requires **read/query** access on the target
415
+ * list (not `update`). When query access returns a filter object, the filter is
416
+ * evaluated in the DATABASE (not in memory) via
417
+ * `findFirst({ where: { AND: [connection, accessFilter] } })`. The connect is
418
+ * allowed iff that query returns a row, which correctly handles arbitrary
419
+ * nested-relation predicates and boolean combinators (`AND`/`OR`/`some`/
420
+ * `none`/`not`). The existence check is folded into the reachability query so a
421
+ * non-existent id is still denied.
422
+ *
423
+ * In ADDITION to the target read/reachability check (#578), the OWNING
424
+ * relationship field's field-level access (its `create`/`update` access on the
425
+ * list being written, e.g. `Post.author`) must permit the connect (#588). This
426
+ * is the other half Keystone required: a connect needs read access on the
427
+ * target AND write access on the owning relationship field. If the owning
428
+ * field's field-level access denies, the connect is denied even when the target
429
+ * row is readable/reachable.
430
+ *
431
+ * Sudo bypasses the entire check (handled by the caller).
432
+ */
433
+ async function verifyConnectReachable(
434
+ connection: Record<string, unknown>,
435
+ relatedListName: string,
436
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
437
+ relatedListConfig: ListConfig<any>,
438
+ context: AccessContext,
439
+ prisma: unknown,
440
+ owningFieldAccess: FieldAccess | undefined,
441
+ enclosingOperation: 'create' | 'update',
442
+ enclosingItem: Record<string, unknown> | undefined,
443
+ enclosingInputData: Record<string, unknown> | undefined,
444
+ ): Promise<void> {
445
+ // Access Prisma model dynamically - required because model names are generated at runtime
446
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
447
+ const model = (prisma as any)[getDbKey(relatedListName)]
448
+
449
+ // #588 — gate the connect by the OWNING relationship field's field-level
450
+ // access (evaluated for the enclosing write's operation). This runs in
451
+ // addition to the target read/reachability check below; a deny here denies
452
+ // the connect even if the target row is readable. `checkFieldAccess` returns
453
+ // `true` under sudo, but the caller already skips this whole function for
454
+ // sudo, so the gate never fires for trusted writes.
455
+ //
456
+ // `item`/`inputData` are the ENCLOSING write's `originalItem`/`inputData` —
457
+ // the SAME values the canonical Phase-5 `filterWritableFields` call passes for
458
+ // this field — so a field-access rule that depends on `item` or `inputData`
459
+ // (e.g. `({ item }) => item.status === 'draft'`) evaluates identically here and
460
+ // at Phase 5, and the two gates cannot diverge into a spurious connect denial.
461
+ const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
462
+ session: context.session,
463
+ item: enclosingItem,
464
+ inputData: enclosingInputData,
465
+ context,
466
+ })
467
+ if (!owningFieldAllowed) {
468
+ throw new Error('Access denied: Cannot connect to this item')
469
+ }
470
+
471
+ // Connecting references an existing row; it requires READ (query) access on
472
+ // the target, not update access.
473
+ const queryAccess = relatedListConfig.access?.operation?.query
474
+ const accessResult = await checkAccess(queryAccess, {
475
+ session: context.session,
476
+ context,
477
+ })
478
+
479
+ // Explicit denial.
480
+ if (accessResult === false) {
481
+ throw new Error('Access denied: Cannot connect to this item')
482
+ }
483
+
484
+ // Full access: still verify the row exists (keep "Item not found" behaviour).
485
+ if (accessResult === true) {
486
+ const item = await model.findUnique({ where: connection })
487
+ if (!item) {
488
+ throw new Error(`Cannot connect: Item not found`)
489
+ }
490
+ return
491
+ }
492
+
493
+ // Filter result: confirm the row is reachable under the access filter by
494
+ // AND-combining the connection identifier with the filter and querying the DB.
495
+ // A non-existent id and an unreachable row both yield no row → denied. This
496
+ // correctly evaluates arbitrary nested-relation predicates and boolean
497
+ // combinators because the database does the matching, not an in-memory walk.
498
+ const reachable = await model.findFirst({
499
+ where: { AND: [connection, accessResult] },
500
+ })
501
+
502
+ if (!reachable) {
503
+ throw new Error('Access denied: Cannot connect to this item')
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Process nested connect operations.
509
+ * Verifies read (query) access to the items being connected via DB reachability
510
+ * AND the owning relationship field's field-level access (#588).
122
511
  */
123
512
  async function processNestedConnect(
124
513
  connections: Record<string, unknown> | Array<Record<string, unknown>>,
@@ -127,50 +516,27 @@ async function processNestedConnect(
127
516
  relatedListConfig: ListConfig<any>,
128
517
  context: AccessContext,
129
518
  prisma: unknown,
519
+ owningFieldAccess: FieldAccess | undefined,
520
+ enclosingOperation: 'create' | 'update',
521
+ enclosingItem: Record<string, unknown> | undefined,
522
+ enclosingInputData: Record<string, unknown> | undefined,
130
523
  ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
131
524
  const connectionsArray = Array.isArray(connections) ? connections : [connections]
132
525
 
133
- // Check update access for each item being connected (skip if sudo mode)
526
+ // Check read access for each item being connected (skip if sudo mode)
134
527
  if (!context._isSudo) {
135
528
  for (const connection of connectionsArray) {
136
- // Access Prisma model dynamically - required because model names are generated at runtime
137
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
- const model = (prisma as any)[getDbKey(relatedListName)]
139
-
140
- // Fetch the item to check access
141
- const item = await model.findUnique({
142
- where: connection,
143
- })
144
-
145
- if (!item) {
146
- throw new Error(`Cannot connect: Item not found`)
147
- }
148
-
149
- // Check update access (connecting modifies the relationship)
150
- const updateAccess = relatedListConfig.access?.operation?.update
151
- const accessResult = await checkAccess(updateAccess, {
152
- session: context.session,
153
- item,
529
+ await verifyConnectReachable(
530
+ connection,
531
+ relatedListName,
532
+ relatedListConfig,
154
533
  context,
155
- })
156
-
157
- if (accessResult === false) {
158
- throw new Error('Access denied: Cannot connect to this item')
159
- }
160
-
161
- // If access returns a filter, check if item matches
162
- if (typeof accessResult === 'object') {
163
- // Simple field matching
164
- for (const [key, value] of Object.entries(accessResult)) {
165
- if (typeof value === 'object' && value !== null && 'equals' in value) {
166
- if (item[key] !== (value as Record<string, unknown>).equals) {
167
- throw new Error('Access denied: Cannot connect to this item')
168
- }
169
- } else if (item[key] !== value) {
170
- throw new Error('Access denied: Cannot connect to this item')
171
- }
172
- }
173
- }
534
+ prisma,
535
+ owningFieldAccess,
536
+ enclosingOperation,
537
+ enclosingItem,
538
+ enclosingInputData,
539
+ )
174
540
  }
175
541
  }
176
542
 
@@ -178,17 +544,22 @@ async function processNestedConnect(
178
544
  }
179
545
 
180
546
  /**
181
- * Process nested update operations
182
- * Applies hooks and access control to updates
547
+ * Process nested update operations.
548
+ *
549
+ * Runs the target list's full update input pipeline AND its `beforeOperation`,
550
+ * then registers an `afterOperation` task receiving `originalItem` (the row
551
+ * fetched before the write) and the persisted updated `item`.
183
552
  */
184
553
  async function processNestedUpdate(
185
554
  updates: Record<string, unknown> | Array<Record<string, unknown>>,
555
+ fieldName: string,
186
556
  relatedListName: string,
187
557
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
188
558
  relatedListConfig: ListConfig<any>,
189
559
  context: AccessContext,
190
560
  config: OpenSaasConfig,
191
561
  prisma: unknown,
562
+ afterTasks: AfterTask[],
192
563
  ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
193
564
  const updatesArray = Array.isArray(updates) ? updates : [updates]
194
565
 
@@ -198,21 +569,25 @@ async function processNestedUpdate(
198
569
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
570
  const model = (prisma as any)[getDbKey(relatedListName)]
200
571
 
201
- // Fetch the existing item
202
- const item = await model.findUnique({
203
- where: (update as Record<string, unknown>).where,
204
- })
572
+ const where = (update as Record<string, unknown>).where as Record<string, unknown>
205
573
 
206
- if (!item) {
574
+ // Fetch the existing item — reused as `originalItem` for afterOperation.
575
+ const originalItem = await model.findUnique({ where })
576
+
577
+ if (!originalItem) {
207
578
  throw new Error('Cannot update: Item not found')
208
579
  }
209
580
 
581
+ // The updated row's id is known up front, so the included-result read-back
582
+ // finds this row directly by id.
583
+ const knownId = typeof originalItem.id === 'string' ? (originalItem.id as string) : undefined
584
+
210
585
  // Check update access (skip if sudo mode)
211
586
  if (!context._isSudo) {
212
587
  const updateAccess = relatedListConfig.access?.operation?.update
213
588
  const accessResult = await checkAccess(updateAccess, {
214
589
  session: context.session,
215
- item,
590
+ item: originalItem,
216
591
  context,
217
592
  })
218
593
 
@@ -228,7 +603,7 @@ async function processNestedUpdate(
228
603
  operation: 'update',
229
604
  inputData: updateData,
230
605
  resolvedData: updateData,
231
- item,
606
+ item: originalItem,
232
607
  context,
233
608
  })
234
609
 
@@ -240,7 +615,7 @@ async function processNestedUpdate(
240
615
  'update',
241
616
  context,
242
617
  relatedListName,
243
- item,
618
+ originalItem,
244
619
  )
245
620
 
246
621
  // Execute validate hook
@@ -249,11 +624,22 @@ async function processNestedUpdate(
249
624
  operation: 'update',
250
625
  inputData: updateData,
251
626
  resolvedData,
252
- item,
627
+ item: originalItem,
253
628
  context,
254
629
  })
255
630
 
256
- // Field validation
631
+ // Field-level validate hooks
632
+ await executeFieldValidateHooks(
633
+ updateData,
634
+ resolvedData,
635
+ relatedListConfig.fields,
636
+ 'update',
637
+ context,
638
+ relatedListName,
639
+ originalItem,
640
+ )
641
+
642
+ // Field validation (built-in rules)
257
643
  const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'update')
258
644
  if (validation.errors.length > 0) {
259
645
  throw new ValidationError(validation.errors, validation.fieldErrors)
@@ -266,24 +652,84 @@ async function processNestedUpdate(
266
652
  'update',
267
653
  {
268
654
  session: context.session,
269
- item,
655
+ item: originalItem,
270
656
  context,
271
657
  inputData: updateData,
272
658
  },
273
659
  )
274
660
 
275
- // Recursively process nested operations
276
- const processedData = await processNestedOperations(
661
+ // Recursively process nested operations. This nested row is being UPDATED,
662
+ // so its own relations' pre-existing rows are captured from `originalItem`.
663
+ const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(
277
664
  filtered,
278
665
  relatedListConfig.fields,
279
666
  config,
280
- context,
667
+ { ...context, prisma },
668
+ 'update',
669
+ relatedListName,
670
+ originalItem,
671
+ // This nested row is being UPDATED, so its enclosing inputData is its own
672
+ // update payload (passed to the connect-site owning-field gate, #588).
673
+ updateData,
674
+ )
675
+
676
+ // Field-level beforeOperation (side effects)
677
+ await executeFieldBeforeOperationHooks(
678
+ updateData,
679
+ resolvedData,
680
+ relatedListConfig.fields,
281
681
  'update',
682
+ context,
683
+ relatedListName,
684
+ originalItem,
282
685
  )
283
686
 
687
+ // List-level beforeOperation
688
+ await executeBeforeOperation(relatedListConfig.hooks, {
689
+ listKey: relatedListName,
690
+ operation: 'update',
691
+ inputData: updateData,
692
+ item: originalItem,
693
+ resolvedData,
694
+ context,
695
+ })
696
+
697
+ // Register afterOperation: fires after the parent persist. The updated row
698
+ // is recovered from the parent's included relation by its known id.
699
+ afterTasks.push({
700
+ fieldName,
701
+ run: async (parentResult) => {
702
+ const persisted = recoverUpdatedRow(parentResult, fieldName, knownId)
703
+ const updatedItem = persisted ?? originalItem
704
+
705
+ await executeAfterOperation(relatedListConfig.hooks, {
706
+ listKey: relatedListName,
707
+ operation: 'update',
708
+ inputData: updateData,
709
+ originalItem,
710
+ item: updatedItem,
711
+ resolvedData,
712
+ context,
713
+ })
714
+
715
+ await executeFieldAfterOperationHooks(
716
+ updatedItem,
717
+ updateData,
718
+ resolvedData,
719
+ relatedListConfig.fields,
720
+ 'update',
721
+ context,
722
+ relatedListName,
723
+ originalItem,
724
+ )
725
+
726
+ await runAfterTasks(childAfterTasks, updatedItem)
727
+ },
728
+ })
729
+
284
730
  return {
285
- where: (update as Record<string, unknown>).where,
286
- data: processedData,
731
+ where,
732
+ data: nestedData,
287
733
  }
288
734
  }),
289
735
  )
@@ -291,61 +737,237 @@ async function processNestedUpdate(
291
737
  return Array.isArray(updates) ? processedUpdates : processedUpdates[0]
292
738
  }
293
739
 
740
+ /**
741
+ * Process nested delete operations.
742
+ *
743
+ * Runs the target list's delete pipeline (validate/field-validate +
744
+ * `beforeOperation`) before the parent persist, and registers an
745
+ * `afterOperation` task receiving the `originalItem` (the row before deletion).
746
+ * Persistence is performed by Prisma's nested write; the row no longer exists
747
+ * after, so `originalItem` is the authoritative record for after-hooks.
748
+ */
749
+ async function processNestedDelete(
750
+ deletes: Record<string, unknown> | Array<Record<string, unknown>> | boolean,
751
+ relatedListName: string,
752
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
753
+ relatedListConfig: ListConfig<any>,
754
+ context: AccessContext,
755
+ prisma: unknown,
756
+ afterTasks: AfterTask[],
757
+ ): Promise<Record<string, unknown> | Array<Record<string, unknown>> | boolean> {
758
+ // A to-one relation delete can be a boolean (`{ delete: true }`); there is no
759
+ // identifying `where`, so we cannot run target-resolved hooks. Pass through.
760
+ if (typeof deletes === 'boolean') {
761
+ return deletes
762
+ }
763
+
764
+ const deletesArray = Array.isArray(deletes) ? deletes : [deletes]
765
+
766
+ await Promise.all(
767
+ deletesArray.map(async (del) => {
768
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
769
+ const model = (prisma as any)[getDbKey(relatedListName)]
770
+
771
+ // A nested delete entry is itself the unique `where` (e.g. `{ id }`).
772
+ const where = del as Record<string, unknown>
773
+
774
+ const originalItem = await model.findUnique({ where })
775
+ if (!originalItem) {
776
+ throw new Error('Cannot delete: Item not found')
777
+ }
778
+
779
+ // Check delete access (skip if sudo mode)
780
+ if (!context._isSudo) {
781
+ const deleteAccess = relatedListConfig.access?.operation?.delete
782
+ const accessResult = await checkAccess(deleteAccess, {
783
+ session: context.session,
784
+ item: originalItem,
785
+ context,
786
+ })
787
+
788
+ if (accessResult === false) {
789
+ throw new Error('Access denied: Cannot delete related item')
790
+ }
791
+ }
792
+
793
+ // List-level validate (delete)
794
+ await executeValidate(relatedListConfig.hooks, {
795
+ listKey: relatedListName,
796
+ operation: 'delete',
797
+ item: originalItem,
798
+ context,
799
+ })
800
+
801
+ // Field-level validate (delete)
802
+ await executeFieldValidateHooks(
803
+ undefined,
804
+ undefined,
805
+ relatedListConfig.fields,
806
+ 'delete',
807
+ context,
808
+ relatedListName,
809
+ originalItem,
810
+ )
811
+
812
+ // Field-level beforeOperation (delete)
813
+ await executeFieldBeforeOperationHooks(
814
+ {},
815
+ {},
816
+ relatedListConfig.fields,
817
+ 'delete',
818
+ context,
819
+ relatedListName,
820
+ originalItem,
821
+ )
822
+
823
+ // List-level beforeOperation (delete)
824
+ await executeBeforeOperation(relatedListConfig.hooks, {
825
+ listKey: relatedListName,
826
+ operation: 'delete',
827
+ item: originalItem,
828
+ context,
829
+ })
830
+
831
+ // Register afterOperation: the row is gone after persist, so the
832
+ // originalItem is the authoritative record passed to after-hooks.
833
+ afterTasks.push({
834
+ fieldName: '',
835
+ run: async () => {
836
+ await executeAfterOperation(relatedListConfig.hooks, {
837
+ listKey: relatedListName,
838
+ operation: 'delete',
839
+ originalItem,
840
+ context,
841
+ })
842
+
843
+ await executeFieldAfterOperationHooks(
844
+ originalItem,
845
+ undefined,
846
+ undefined,
847
+ relatedListConfig.fields,
848
+ 'delete',
849
+ context,
850
+ relatedListName,
851
+ originalItem,
852
+ )
853
+ },
854
+ })
855
+ }),
856
+ )
857
+
858
+ return deletes
859
+ }
860
+
294
861
  /**
295
862
  * Process nested connectOrCreate operations
296
863
  */
297
864
  async function processNestedConnectOrCreate(
298
865
  operations: Record<string, unknown> | Array<Record<string, unknown>>,
866
+ fieldName: string,
299
867
  relatedListName: string,
300
868
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
301
869
  relatedListConfig: ListConfig<any>,
302
870
  context: AccessContext,
303
871
  config: OpenSaasConfig,
304
872
  prisma: unknown,
873
+ afterTasks: AfterTask[],
874
+ recovery: CreatedRowRecovery,
875
+ owningFieldAccess: FieldAccess | undefined,
876
+ enclosingOperation: 'create' | 'update',
877
+ enclosingItem: Record<string, unknown> | undefined,
878
+ enclosingInputData: Record<string, unknown> | undefined,
305
879
  ): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
306
880
  const operationsArray = Array.isArray(operations) ? operations : [operations]
307
881
 
308
882
  const processedOps = await Promise.all(
309
883
  operationsArray.map(async (op) => {
310
- // Process the create portion through create hooks
311
884
  const opRecord = op as Record<string, unknown>
312
- const processedCreate = await processNestedCreate(
313
- opRecord.create as Record<string, unknown> | Array<Record<string, unknown>>,
314
- relatedListConfig,
315
- context,
316
- config,
317
- )
318
885
 
319
- // Check access for the connect portion (try to find existing item) (skip if sudo mode)
886
+ // Check access for the connect portion (skip if sudo mode).
887
+ //
888
+ // connectOrCreate connects an existing row when present, otherwise
889
+ // creates. So when the row exists we apply the same connect semantics as
890
+ // processNestedConnect — READ (query) access on the target, evaluated via
891
+ // DB reachability for filter results, PLUS the owning relationship field's
892
+ // field-level access (#588). When the row does not exist we fall through to
893
+ // create. We must NOT swallow an access-denied error: only the genuine
894
+ // "row absent" case may fall back to create.
895
+ let rowExists = false
320
896
  if (!context._isSudo) {
321
- try {
322
- // Access Prisma model dynamically - required because model names are generated at runtime
323
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
- const model = (prisma as any)[getDbKey(relatedListName)]
325
- const existingItem = await model.findUnique({
326
- where: opRecord.where,
897
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
898
+ const model = (prisma as any)[getDbKey(relatedListName)]
899
+ const where = opRecord.where as Record<string, unknown>
900
+
901
+ const existingItem = await model.findUnique({ where })
902
+
903
+ // Only enforce connect access when the row actually exists; otherwise
904
+ // the create branch is used.
905
+ if (existingItem) {
906
+ rowExists = true
907
+
908
+ // #588 — gate the connect branch by the OWNING relationship field's
909
+ // field-level access, identical to processNestedConnect. A deny here
910
+ // denies the connect even if the target row is readable/reachable.
911
+ // `item`/`inputData` are the ENCLOSING write's `originalItem`/
912
+ // `inputData` (the same values Phase-5 `filterWritableFields` passes),
913
+ // so item-/inputData-dependent field rules cannot diverge between the
914
+ // two gates.
915
+ const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
916
+ session: context.session,
917
+ item: enclosingItem,
918
+ inputData: enclosingInputData,
919
+ context,
327
920
  })
921
+ if (!owningFieldAllowed) {
922
+ throw new Error('Access denied: Cannot connect to existing item')
923
+ }
924
+
925
+ const queryAccess = relatedListConfig.access?.operation?.query
926
+ const accessResult = await checkAccess(queryAccess, {
927
+ session: context.session,
928
+ item: existingItem,
929
+ context,
930
+ })
931
+
932
+ if (accessResult === false) {
933
+ throw new Error('Access denied: Cannot connect to existing item')
934
+ }
328
935
 
329
- if (existingItem) {
330
- // Check update access for connection
331
- const updateAccess = relatedListConfig.access?.operation?.update
332
- const accessResult = await checkAccess(updateAccess, {
333
- session: context.session,
334
- item: existingItem,
335
- context,
936
+ // Filter result: confirm the existing row is reachable under the
937
+ // access filter via DB reachability (handles nested/boolean filters).
938
+ if (accessResult !== true) {
939
+ const reachable = await model.findFirst({
940
+ where: { AND: [where, accessResult] },
336
941
  })
337
942
 
338
- if (accessResult === false) {
943
+ if (!reachable) {
339
944
  throw new Error('Access denied: Cannot connect to existing item')
340
945
  }
341
946
  }
342
- } catch {
343
- // Item doesn't exist, will use create (already processed)
344
947
  }
345
948
  }
346
949
 
950
+ // Process the create portion through the full create pipeline (incl.
951
+ // before/afterOperation). Only register an afterOperation task when the
952
+ // create branch will actually run (row absent), so a pure connect does not
953
+ // fire create hooks. Under sudo we cannot statically know, so we let the
954
+ // create pipeline run its hooks (sudo bypasses access only, not hooks).
955
+ const runCreateHooks = context._isSudo || !rowExists
956
+ const createAfterTasks: AfterTask[] = runCreateHooks ? afterTasks : []
957
+ const processedCreate = await processNestedCreate(
958
+ opRecord.create as Record<string, unknown> | Array<Record<string, unknown>>,
959
+ fieldName,
960
+ relatedListName,
961
+ relatedListConfig,
962
+ context,
963
+ config,
964
+ prisma,
965
+ createAfterTasks,
966
+ recovery,
967
+ )
968
+
347
969
  return {
348
- where: (op as Record<string, unknown>).where,
970
+ where: opRecord.where,
349
971
  create: processedCreate,
350
972
  }
351
973
  }),
@@ -356,100 +978,230 @@ async function processNestedConnectOrCreate(
356
978
 
357
979
  /**
358
980
  * Arguments passed to every nested-operation handler.
359
- *
360
- * A handler receives the raw value supplied for a single nested-op kind
361
- * (e.g. the contents of `value.create`) alongside everything it needs to apply
362
- * hooks, access control, and recursion.
363
981
  */
364
982
  interface NestedOpHandlerArgs {
365
983
  /** Raw payload supplied for this nested-op kind (e.g. the value of `value.create`). */
366
984
  value: unknown
985
+ /** The owning relationship field name on the parent (for include read-back). */
986
+ fieldName: string
367
987
  /** The list name of the related model (e.g. `'User'`). */
368
988
  relatedListName: string
369
989
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
370
990
  relatedListConfig: ListConfig<any>
991
+ /**
992
+ * Field-level `access` of the OWNING relationship field on the list being
993
+ * written (e.g. `Post.author`). Used by the connect/connectOrCreate handlers
994
+ * to gate connects by the owning field's create/update access (#588).
995
+ */
996
+ owningFieldAccess: FieldAccess | undefined
997
+ /**
998
+ * The enclosing write's operation (`create`/`update`), used as the field-access
999
+ * operation for the owning-field connect gate (#588).
1000
+ */
1001
+ enclosingOperation: 'create' | 'update'
1002
+ /**
1003
+ * The enclosing write's existing row (the parent `originalItem`): present for an
1004
+ * enclosing UPDATE, `undefined` for an enclosing CREATE. Threaded into the
1005
+ * connect-site owning-field gate so it evaluates `item` exactly like the
1006
+ * canonical Phase-5 `filterWritableFields` call and the two cannot diverge
1007
+ * (#588 finding).
1008
+ */
1009
+ enclosingItem: Record<string, unknown> | undefined
1010
+ /**
1011
+ * The enclosing write's input data. Threaded into the connect-site owning-field
1012
+ * gate so it evaluates `inputData` exactly like the canonical Phase-5
1013
+ * `filterWritableFields` call (#588 finding).
1014
+ */
1015
+ enclosingInputData: Record<string, unknown> | undefined
371
1016
  context: AccessContext
372
1017
  config: OpenSaasConfig
373
1018
  /** Prisma client used for dynamic model access during access checks. */
374
1019
  prisma: unknown
1020
+ /** Collector for deferred nested `afterOperation` tasks. */
1021
+ afterTasks: AfterTask[]
1022
+ /**
1023
+ * Recovery of the rows created on THIS field by id-diff (created kinds only —
1024
+ * `create` and `connectOrCreate`'s create branch). Identifies each created row
1025
+ * by excluding the ids that existed before the persist, and pairs them to the
1026
+ * create-payload entries by position. Created lazily because it requires a
1027
+ * pre-persist DB read; `undefined` for kinds that never create.
1028
+ */
1029
+ recovery: CreatedRowRecovery | undefined
1030
+ }
1031
+
1032
+ /**
1033
+ * Narrow the lazily-built {@link CreatedRowRecovery} to a present value for the
1034
+ * created kinds (`create`, `connectOrCreate`). It is always provided for these
1035
+ * kinds by {@link processFieldNestedOps}; the guard backstops a programming
1036
+ * error rather than a user-facing path.
1037
+ */
1038
+ function requireRecovery(
1039
+ recovery: CreatedRowRecovery | undefined,
1040
+ kind: string,
1041
+ ): CreatedRowRecovery {
1042
+ if (!recovery) {
1043
+ throw new Error(`Internal error: missing created-row recovery for nested "${kind}"`)
1044
+ }
1045
+ return recovery
375
1046
  }
376
1047
 
1048
+ /** Nested-op kinds that can create new rows and so need created-row recovery. */
1049
+ const CREATING_KINDS = new Set(['create', 'connectOrCreate'])
1050
+
377
1051
  /**
378
1052
  * A nested-operation handler describes how a single nested-op kind
379
1053
  * (`create`, `connect`, …) is processed before it reaches Prisma.
380
- *
381
- * Adding support for a new nested-op kind means registering a new entry in
382
- * {@link nestedOpRegistry}, not editing the dispatch loop.
383
1054
  */
384
1055
  interface NestedOpHandler {
385
1056
  /** Produce the processed payload for this nested-op kind. */
386
1057
  execute(args: NestedOpHandlerArgs): Promise<unknown>
1058
+ /**
1059
+ * Whether this kind needs the parent write to `include` the relation so its
1060
+ * persisted row can be read back for `afterOperation` (`create`/`update`).
1061
+ */
1062
+ needsInclude: boolean
387
1063
  }
388
1064
 
389
1065
  /**
390
1066
  * Registry of nested-operation handlers keyed by nested-op kind.
391
1067
  *
392
- * The dispatch loop in {@link processNestedOperations} looks handlers up here
393
- * instead of branching on each kind. Kinds that require hooks/access control
394
- * (`create`, `connect`, `connectOrCreate`, `update`) provide an `execute` that
395
- * applies them; pass-through kinds (`disconnect`, `delete`, `deleteMany`,
396
- * `set`, `updateMany`) return their value unchanged so Prisma's own
397
- * constraints apply.
1068
+ * Kinds that run the full hook pipeline (`create`, `update`, `delete`, and the
1069
+ * create branch of `connectOrCreate`) run `beforeOperation` inline and register
1070
+ * deferred `afterOperation` tasks. `connect`/`connectOrCreate`'s connect branch
1071
+ * enforce access only. Remaining pass-through kinds (`disconnect`, `set`,
1072
+ * `updateMany`, `deleteMany`) return their value unchanged so Prisma's own
1073
+ * constraints apply — they are intentionally NOT in scope for #569.
398
1074
  */
399
1075
  const nestedOpRegistry: Record<string, NestedOpHandler> = {
400
1076
  create: {
401
- execute: ({ value, relatedListConfig, context, config }) =>
1077
+ needsInclude: true,
1078
+ execute: ({
1079
+ value,
1080
+ fieldName,
1081
+ relatedListName,
1082
+ relatedListConfig,
1083
+ context,
1084
+ config,
1085
+ prisma,
1086
+ afterTasks,
1087
+ recovery,
1088
+ }) =>
402
1089
  processNestedCreate(
403
1090
  value as Record<string, unknown> | Array<Record<string, unknown>>,
1091
+ fieldName,
1092
+ relatedListName,
404
1093
  relatedListConfig,
405
1094
  context,
406
1095
  config,
1096
+ prisma,
1097
+ afterTasks,
1098
+ requireRecovery(recovery, 'create'),
407
1099
  ),
408
1100
  },
409
1101
  connect: {
410
- execute: ({ value, relatedListName, relatedListConfig, context, prisma }) =>
1102
+ needsInclude: false,
1103
+ execute: ({
1104
+ value,
1105
+ relatedListName,
1106
+ relatedListConfig,
1107
+ context,
1108
+ prisma,
1109
+ owningFieldAccess,
1110
+ enclosingOperation,
1111
+ enclosingItem,
1112
+ enclosingInputData,
1113
+ }) =>
411
1114
  processNestedConnect(
412
1115
  value as Record<string, unknown> | Array<Record<string, unknown>>,
413
1116
  relatedListName,
414
1117
  relatedListConfig,
415
1118
  context,
416
1119
  prisma,
1120
+ owningFieldAccess,
1121
+ enclosingOperation,
1122
+ enclosingItem,
1123
+ enclosingInputData,
417
1124
  ),
418
1125
  },
419
1126
  connectOrCreate: {
420
- execute: ({ value, relatedListName, relatedListConfig, context, config, prisma }) =>
1127
+ needsInclude: true,
1128
+ execute: ({
1129
+ value,
1130
+ fieldName,
1131
+ relatedListName,
1132
+ relatedListConfig,
1133
+ context,
1134
+ config,
1135
+ prisma,
1136
+ afterTasks,
1137
+ recovery,
1138
+ owningFieldAccess,
1139
+ enclosingOperation,
1140
+ enclosingItem,
1141
+ enclosingInputData,
1142
+ }) =>
421
1143
  processNestedConnectOrCreate(
422
1144
  value as Record<string, unknown> | Array<Record<string, unknown>>,
1145
+ fieldName,
423
1146
  relatedListName,
424
1147
  relatedListConfig,
425
1148
  context,
426
1149
  config,
427
1150
  prisma,
1151
+ afterTasks,
1152
+ requireRecovery(recovery, 'connectOrCreate'),
1153
+ owningFieldAccess,
1154
+ enclosingOperation,
1155
+ enclosingItem,
1156
+ enclosingInputData,
428
1157
  ),
429
1158
  },
430
1159
  update: {
431
- execute: ({ value, relatedListName, relatedListConfig, context, config, prisma }) =>
1160
+ needsInclude: true,
1161
+ execute: ({
1162
+ value,
1163
+ fieldName,
1164
+ relatedListName,
1165
+ relatedListConfig,
1166
+ context,
1167
+ config,
1168
+ prisma,
1169
+ afterTasks,
1170
+ }) =>
432
1171
  processNestedUpdate(
433
1172
  value as Record<string, unknown> | Array<Record<string, unknown>>,
1173
+ fieldName,
434
1174
  relatedListName,
435
1175
  relatedListConfig,
436
1176
  context,
437
1177
  config,
438
1178
  prisma,
1179
+ afterTasks,
1180
+ ),
1181
+ },
1182
+ delete: {
1183
+ // The row no longer exists after the parent write, so no read-back include.
1184
+ needsInclude: false,
1185
+ execute: ({ value, relatedListName, relatedListConfig, context, prisma, afterTasks }) =>
1186
+ processNestedDelete(
1187
+ value as Record<string, unknown> | Array<Record<string, unknown>> | boolean,
1188
+ relatedListName,
1189
+ relatedListConfig,
1190
+ context,
1191
+ prisma,
1192
+ afterTasks,
439
1193
  ),
440
1194
  },
441
1195
  // Pass-through kinds: no hooks/access control, left to Prisma's own constraints.
442
- disconnect: { execute: ({ value }) => Promise.resolve(value) },
443
- delete: { execute: ({ value }) => Promise.resolve(value) },
444
- deleteMany: { execute: ({ value }) => Promise.resolve(value) },
445
- set: { execute: ({ value }) => Promise.resolve(value) },
446
- updateMany: { execute: ({ value }) => Promise.resolve(value) },
1196
+ // (Out of scope for #569 see the issue's "Out of scope" notes.)
1197
+ disconnect: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
1198
+ deleteMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
1199
+ set: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
1200
+ updateMany: { needsInclude: false, execute: ({ value }) => Promise.resolve(value) },
447
1201
  }
448
1202
 
449
1203
  /**
450
1204
  * Order in which nested-op kinds are processed for a single relationship field.
451
- *
452
- * Mirrors the historical in-place dispatch order so behaviour is preserved.
453
1205
  */
454
1206
  const nestedOpOrder = [
455
1207
  'create',
@@ -469,11 +1221,33 @@ const nestedOpOrder = [
469
1221
  * the {@link nestedOpRegistry}.
470
1222
  */
471
1223
  async function processFieldNestedOps(
1224
+ fieldName: string,
472
1225
  valueRecord: Record<string, unknown>,
473
- args: Omit<NestedOpHandlerArgs, 'value'>,
1226
+ args: Omit<NestedOpHandlerArgs, 'value' | 'fieldName' | 'recovery'>,
1227
+ includeFields: Set<string>,
1228
+ parentListName: string,
1229
+ parentOriginalItem: Record<string, unknown> | undefined,
474
1230
  ): Promise<Record<string, unknown>> {
475
1231
  const nestedOp: Record<string, unknown> = {}
476
1232
 
1233
+ // Created-row recovery is only needed when this field has a creating kind
1234
+ // (`create`/`connectOrCreate`). When present it requires a pre-persist read of
1235
+ // the parent's current related ids, so build it once, lazily, and share it
1236
+ // across the creating kinds on this field.
1237
+ let recovery: CreatedRowRecovery | undefined
1238
+ const hasCreatingKind = nestedOpOrder.some(
1239
+ (kind) => CREATING_KINDS.has(kind) && valueRecord[kind] !== undefined,
1240
+ )
1241
+ if (hasCreatingKind) {
1242
+ const preExistingIds = await capturePreExistingIds(
1243
+ parentListName,
1244
+ parentOriginalItem,
1245
+ fieldName,
1246
+ args.prisma,
1247
+ )
1248
+ recovery = createCreatedRowRecovery(fieldName, preExistingIds)
1249
+ }
1250
+
477
1251
  for (const kind of nestedOpOrder) {
478
1252
  const value = valueRecord[kind]
479
1253
  if (value === undefined) {
@@ -481,15 +1255,27 @@ async function processFieldNestedOps(
481
1255
  }
482
1256
 
483
1257
  const handler = nestedOpRegistry[kind]
484
- nestedOp[kind] = await handler.execute({ ...args, value })
1258
+ if (handler.needsInclude) {
1259
+ includeFields.add(fieldName)
1260
+ }
1261
+ nestedOp[kind] = await handler.execute({
1262
+ ...args,
1263
+ value,
1264
+ fieldName,
1265
+ recovery,
1266
+ })
485
1267
  }
486
1268
 
487
1269
  return nestedOp
488
1270
  }
489
1271
 
490
1272
  /**
491
- * Process all nested operations in a data payload
492
- * Recursively handles relationship fields with nested writes
1273
+ * Process all nested operations in a data payload.
1274
+ *
1275
+ * Recursively handles relationship fields with nested writes. In addition to
1276
+ * transforming the payload it runs each nested record's `beforeOperation` and
1277
+ * collects deferred `afterOperation` tasks (run by the Write Pipeline after the
1278
+ * parent persist via {@link runAfterTasks}). See ADR-0010.
493
1279
  */
494
1280
  export async function processNestedOperations(
495
1281
  data: Record<string, unknown>,
@@ -497,12 +1283,22 @@ export async function processNestedOperations(
497
1283
  config: OpenSaasConfig,
498
1284
  context: AccessContext & { prisma: unknown },
499
1285
  operation: 'create' | 'update',
1286
+ parentListName: string,
1287
+ parentOriginalItem: Record<string, unknown> | undefined,
1288
+ // The enclosing write's input data (the SAME value Phase-5 `filterWritableFields`
1289
+ // passes as `inputData`). Threaded into the connect-site owning-field gate (#588
1290
+ // finding) so item-/inputData-dependent field-access rules cannot diverge between
1291
+ // Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
1292
+ parentInputData: Record<string, unknown> | undefined = undefined,
500
1293
  depth: number = 0,
501
- ): Promise<Record<string, unknown>> {
1294
+ ): Promise<NestedOpsResult> {
502
1295
  const MAX_DEPTH = 5
503
1296
 
1297
+ const afterTasks: AfterTask[] = []
1298
+ const includeFields = new Set<string>()
1299
+
504
1300
  if (depth >= MAX_DEPTH) {
505
- return data
1301
+ return { data, afterTasks, includeFields }
506
1302
  }
507
1303
 
508
1304
  const processed: Record<string, unknown> = {}
@@ -525,16 +1321,53 @@ export async function processNestedOperations(
525
1321
  }
526
1322
 
527
1323
  const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig
1324
+ // Sanity: ensure the resolved list name matches the config identity.
1325
+ const resolvedListName = relatedListName || findListName(relatedListConfig, config)
528
1326
 
529
- // Dispatch each present nested-op kind through the handler registry.
530
- processed[fieldName] = await processFieldNestedOps(value as Record<string, unknown>, {
531
- relatedListName,
532
- relatedListConfig,
533
- context,
534
- config,
535
- prisma: context.prisma,
536
- })
1327
+ // #588 the owning relationship field's field-level access (e.g. the
1328
+ // `access` on `Post.author`). Threaded into the nested-op handlers so the
1329
+ // connect/connectOrCreate handlers can gate connects by this field's
1330
+ // create/update access, in addition to the target's read access.
1331
+ const owningFieldAccess = fieldConfig.access
1332
+
1333
+ processed[fieldName] = await processFieldNestedOps(
1334
+ fieldName,
1335
+ value as Record<string, unknown>,
1336
+ {
1337
+ relatedListName: resolvedListName,
1338
+ relatedListConfig,
1339
+ owningFieldAccess,
1340
+ enclosingOperation: operation,
1341
+ // The enclosing write's `originalItem`/`inputData` — the SAME values the
1342
+ // canonical Phase-5 `filterWritableFields` call passes for this field — so
1343
+ // the connect-site owning-field gate evaluates item-/inputData-dependent
1344
+ // rules identically and cannot diverge into a spurious connect denial (#588).
1345
+ enclosingItem: parentOriginalItem,
1346
+ enclosingInputData: parentInputData ?? {},
1347
+ context,
1348
+ config,
1349
+ prisma: context.prisma,
1350
+ afterTasks,
1351
+ },
1352
+ includeFields,
1353
+ parentListName,
1354
+ parentOriginalItem,
1355
+ )
537
1356
  }
538
1357
 
539
- return processed
1358
+ return { data: processed, afterTasks, includeFields }
1359
+ }
1360
+
1361
+ /**
1362
+ * Run a set of deferred nested `afterOperation` tasks against a persisted parent
1363
+ * row. Tasks run sequentially so a throwing after-hook aborts the rest (and, run
1364
+ * inside the transaction by the Write Pipeline, rolls the whole write back).
1365
+ */
1366
+ export async function runAfterTasks(
1367
+ afterTasks: AfterTask[],
1368
+ parentResult: Record<string, unknown>,
1369
+ ): Promise<void> {
1370
+ for (const task of afterTasks) {
1371
+ await task.run(parentResult)
1372
+ }
540
1373
  }