@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +223 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- 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
|
-
*
|
|
22
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
106
|
-
|
|
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
|
-
*
|
|
121
|
-
*
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
286
|
-
data:
|
|
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 (
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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<
|
|
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
|
-
//
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
}
|