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