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