@opensaas/stack-core 0.24.0 → 0.25.0

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