@opensaas/stack-core 0.38.0 → 0.39.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 +123 -0
- package/CLAUDE.md +21 -0
- package/dist/access/access-filter.d.ts +46 -1
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +113 -4
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +4 -9
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +49 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +75 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +0 -4
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +13 -39
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +190 -193
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +26 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +14 -117
- package/dist/query/index.js.map +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +152 -5
- package/src/access/declared-dependencies.ts +4 -9
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +81 -0
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +16 -47
- package/src/access/index.ts +20 -2
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +2 -10
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +263 -247
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +29 -235
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/context.test.ts +700 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -19,34 +19,15 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
19
19
|
import { applyCreateDefaults } from './apply-defaults.js'
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Nested writes (#569 / ADR-0010)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* write (so Prisma keeps owning FK ordering and intra-statement atomicity); we
|
|
29
|
-
* run the nested records' `beforeOperation` BEFORE that persist and their
|
|
30
|
-
* `afterOperation` AFTER it, all inside the one interactive transaction the
|
|
31
|
-
* Write Pipeline opens.
|
|
32
|
-
*
|
|
33
|
-
* Mechanism (per ADR-0010, "hooks around a single nested persist"):
|
|
34
|
-
* - `processNestedOperations` runs nested resolveInput/validate/field-rules
|
|
35
|
-
* (as before) AND nested `beforeOperation`, and returns the transformed
|
|
36
|
-
* payload together with a list of deferred {@link AfterTask}s.
|
|
37
|
-
* - The Write Pipeline persists the parent (with the nested relations
|
|
38
|
-
* `include`d so the persisted nested rows come back), then calls
|
|
39
|
-
* {@link runAfterTasks} so each nested record's `afterOperation` fires with
|
|
40
|
-
* a real persisted `item` and (for update/delete) its `originalItem`.
|
|
41
|
-
* - Everything runs inside the transaction, so a throwing `beforeOperation`/
|
|
42
|
-
* `afterOperation` rolls back the whole write.
|
|
22
|
+
* Nested writes (#569 / ADR-0010): give nested `create`/`update`/`delete` the
|
|
23
|
+
* same before/afterOperation hooks as an equivalent top-level write, inside
|
|
24
|
+
* the one transaction the Write Pipeline opens. `processNestedOperations`
|
|
25
|
+
* transforms the payload and runs `beforeOperation`, returning deferred
|
|
26
|
+
* {@link AfterTask}s that {@link runAfterTasks} runs once the parent has
|
|
27
|
+
* persisted. See ADR-0010 for the full mechanism.
|
|
43
28
|
*/
|
|
44
29
|
|
|
45
|
-
/**
|
|
46
|
-
* A deferred nested `afterOperation` task, run after the parent has persisted.
|
|
47
|
-
* It receives the persisted parent row (with nested relations included) so it
|
|
48
|
-
* can recover the persisted nested `item`.
|
|
49
|
-
*/
|
|
30
|
+
/** A deferred nested `afterOperation` task, run once the parent has persisted. */
|
|
50
31
|
export interface AfterTask {
|
|
51
32
|
/** Field name on the parent linking to the related list (for include lookup). */
|
|
52
33
|
fieldName: string
|
|
@@ -67,16 +48,11 @@ export interface NestedOpsResult {
|
|
|
67
48
|
includeFields: Set<string>
|
|
68
49
|
}
|
|
69
50
|
|
|
70
|
-
/**
|
|
71
|
-
* Check if a field config is a relationship field
|
|
72
|
-
*/
|
|
73
51
|
function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
|
|
74
52
|
return fieldConfig?.type === 'relationship'
|
|
75
53
|
}
|
|
76
54
|
|
|
77
|
-
/**
|
|
78
|
-
* Resolve the related list name for a related list config (config object identity).
|
|
79
|
-
*/
|
|
55
|
+
/** Resolve a list name by matching config object identity, not by name. */
|
|
80
56
|
function findListName(
|
|
81
57
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
82
58
|
relatedListConfig: ListConfig<any>,
|
|
@@ -94,8 +70,7 @@ function findListName(
|
|
|
94
70
|
* Read the rows of a parent's included relation as an array.
|
|
95
71
|
*
|
|
96
72
|
* A to-one relation comes back as a single row (or `null`); a to-many relation
|
|
97
|
-
* comes back as an array.
|
|
98
|
-
* a uniform id-diff.
|
|
73
|
+
* comes back as an array. Normalises both so callers can apply a uniform id-diff.
|
|
99
74
|
*/
|
|
100
75
|
function includedRows(
|
|
101
76
|
parentResult: Record<string, unknown>,
|
|
@@ -123,13 +98,10 @@ function recoverUpdatedRow(
|
|
|
123
98
|
}
|
|
124
99
|
|
|
125
100
|
/**
|
|
126
|
-
* Recover the CREATED nested rows from the parent result by id-diff
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* ids captured before the persist). Returned in include order, which the create
|
|
131
|
-
* handler pairs to its create-payload entries by position (see
|
|
132
|
-
* {@link CreatedRowRecovery}).
|
|
101
|
+
* Recover the CREATED nested rows from the parent result by id-diff: the
|
|
102
|
+
* included rows whose ids are NOT in `preExistingIds`. Returned in include
|
|
103
|
+
* order, which {@link CreatedRowRecovery} pairs to create-payload entries by
|
|
104
|
+
* position — reordering this would break that pairing.
|
|
133
105
|
*/
|
|
134
106
|
function recoverCreatedRows(
|
|
135
107
|
parentResult: Record<string, unknown>,
|
|
@@ -143,19 +115,13 @@ function recoverCreatedRows(
|
|
|
143
115
|
|
|
144
116
|
/**
|
|
145
117
|
* Shared, memoised recovery of the rows created for ONE nested `create` payload
|
|
146
|
-
* on ONE relation field
|
|
118
|
+
* on ONE relation field, by id-diff against the parent's pre-persist related
|
|
119
|
+
* ids, paired to create-payload entries by position. See ADR-0010. The id-diff
|
|
120
|
+
* is cached per parent result so every entry's task shares it.
|
|
147
121
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* NEW rows by id-diff against the ids that existed before the persist, then pair
|
|
152
|
-
* them to the create-payload entries by POSITION (Prisma preserves create-array
|
|
153
|
-
* order in the included result). The id-diff is computed once per parent result
|
|
154
|
-
* and cached so every entry's task shares it.
|
|
155
|
-
*
|
|
156
|
-
* `inputData`↔row pairing is therefore positional and best-effort; `item`
|
|
157
|
-
* correctness (each task gets a genuinely-created, distinct row) is guaranteed:
|
|
158
|
-
* a pre-existing row can never be returned because it is excluded by the diff.
|
|
122
|
+
* `inputData`↔row pairing is positional and best-effort; `item` correctness
|
|
123
|
+
* (each task gets a genuinely-created, distinct row) is guaranteed — a
|
|
124
|
+
* pre-existing row can never be returned because the diff excludes it.
|
|
159
125
|
*/
|
|
160
126
|
interface CreatedRowRecovery {
|
|
161
127
|
/** Recover the created row for the create-payload entry at `index`. */
|
|
@@ -182,14 +148,11 @@ function createCreatedRowRecovery(
|
|
|
182
148
|
|
|
183
149
|
/**
|
|
184
150
|
* Capture the ids of the rows currently linked to the parent via `fieldName`,
|
|
185
|
-
* BEFORE the parent persists
|
|
186
|
-
* (
|
|
151
|
+
* BEFORE the parent persists, so created rows can later be identified by id-diff.
|
|
152
|
+
* Empty for a parent CREATE (no parent row exists yet to have related rows).
|
|
187
153
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* - For a parent UPDATE we read the parent row's current relation and collect
|
|
191
|
-
* its ids. The same `tx` client is used so the read participates in the
|
|
192
|
-
* transaction and sees a consistent snapshot.
|
|
154
|
+
* `prisma` must be the transaction client — the read has to participate in the
|
|
155
|
+
* transaction to see a consistent snapshot.
|
|
193
156
|
*/
|
|
194
157
|
async function capturePreExistingIds(
|
|
195
158
|
parentListName: string,
|
|
@@ -220,11 +183,9 @@ async function capturePreExistingIds(
|
|
|
220
183
|
}
|
|
221
184
|
|
|
222
185
|
/**
|
|
223
|
-
* Process nested create operations
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* field-rules → filter-writable → recurse) AND its `beforeOperation`, then
|
|
227
|
-
* registers an `afterOperation` task keyed to the parent's included relation.
|
|
186
|
+
* Process nested create operations: run the target list's full input pipeline
|
|
187
|
+
* and `beforeOperation`, then register an `afterOperation` task keyed to the
|
|
188
|
+
* parent's included relation.
|
|
228
189
|
*/
|
|
229
190
|
async function processNestedCreate(
|
|
230
191
|
items: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
@@ -242,7 +203,6 @@ async function processNestedCreate(
|
|
|
242
203
|
|
|
243
204
|
const processedItems = await Promise.all(
|
|
244
205
|
itemsArray.map(async (item, index) => {
|
|
245
|
-
// 1. Check create access (skip if sudo mode)
|
|
246
206
|
if (!context._isSudo) {
|
|
247
207
|
const createAccess = relatedListConfig.access?.operation?.create
|
|
248
208
|
const accessResult = await checkAccess(createAccess, {
|
|
@@ -255,7 +215,6 @@ async function processNestedCreate(
|
|
|
255
215
|
}
|
|
256
216
|
}
|
|
257
217
|
|
|
258
|
-
// 2. Execute list-level resolveInput hook
|
|
259
218
|
let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
|
|
260
219
|
listKey: relatedListName,
|
|
261
220
|
operation: 'create',
|
|
@@ -265,7 +224,6 @@ async function processNestedCreate(
|
|
|
265
224
|
context,
|
|
266
225
|
})
|
|
267
226
|
|
|
268
|
-
// 3. Execute field-level resolveInput hooks
|
|
269
227
|
resolvedData = await executeFieldResolveInputHooks(
|
|
270
228
|
item,
|
|
271
229
|
resolvedData,
|
|
@@ -275,13 +233,11 @@ async function processNestedCreate(
|
|
|
275
233
|
relatedListName,
|
|
276
234
|
)
|
|
277
235
|
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// `isRequired`. Create-only; explicit values (incl. null) are preserved.
|
|
236
|
+
// Apply field defaults to omitted inputs (resolve-then-validate, #615) so
|
|
237
|
+
// a nested required-with-default field resolves before `isRequired` runs,
|
|
238
|
+
// mirroring the top-level pipeline. Create-only.
|
|
282
239
|
resolvedData = applyCreateDefaults(resolvedData, relatedListConfig.fields)
|
|
283
240
|
|
|
284
|
-
// 4. Execute validate hook
|
|
285
241
|
await executeValidate(relatedListConfig.hooks, {
|
|
286
242
|
listKey: relatedListName,
|
|
287
243
|
operation: 'create',
|
|
@@ -291,7 +247,6 @@ async function processNestedCreate(
|
|
|
291
247
|
context,
|
|
292
248
|
})
|
|
293
249
|
|
|
294
|
-
// 4.5 Field-level validate hooks
|
|
295
250
|
await executeFieldValidateHooks(
|
|
296
251
|
item,
|
|
297
252
|
resolvedData,
|
|
@@ -301,14 +256,13 @@ async function processNestedCreate(
|
|
|
301
256
|
relatedListName,
|
|
302
257
|
)
|
|
303
258
|
|
|
304
|
-
// 5. Field validation (built-in rules)
|
|
305
259
|
const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'create')
|
|
306
260
|
if (validation.errors.length > 0) {
|
|
307
261
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
308
262
|
}
|
|
309
263
|
|
|
310
|
-
//
|
|
311
|
-
//
|
|
264
|
+
// Split multi-column fields into physical columns (#789) — must run after
|
|
265
|
+
// validation, which operates on the logical (pre-split) value.
|
|
312
266
|
resolvedData = await splitMultiColumnFields(
|
|
313
267
|
item,
|
|
314
268
|
resolvedData,
|
|
@@ -317,7 +271,6 @@ async function processNestedCreate(
|
|
|
317
271
|
context,
|
|
318
272
|
)
|
|
319
273
|
|
|
320
|
-
// 6. Filter writable fields
|
|
321
274
|
const filtered = await filterWritableFields(
|
|
322
275
|
resolvedData,
|
|
323
276
|
relatedListConfig.fields,
|
|
@@ -329,9 +282,8 @@ async function processNestedCreate(
|
|
|
329
282
|
},
|
|
330
283
|
)
|
|
331
284
|
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// (parent originalItem is undefined → empty pre-existing set).
|
|
285
|
+
// This nested row is itself being created, so it has no pre-existing
|
|
286
|
+
// related rows of its own (parent originalItem is undefined below).
|
|
335
287
|
const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(
|
|
336
288
|
filtered,
|
|
337
289
|
relatedListConfig.fields,
|
|
@@ -340,12 +292,11 @@ async function processNestedCreate(
|
|
|
340
292
|
'create',
|
|
341
293
|
relatedListName,
|
|
342
294
|
undefined,
|
|
343
|
-
//
|
|
344
|
-
//
|
|
295
|
+
// Its own create payload is the enclosing inputData for the connect-site
|
|
296
|
+
// owning-field gate (#588).
|
|
345
297
|
item,
|
|
346
298
|
)
|
|
347
299
|
|
|
348
|
-
// 8. Field-level beforeOperation (side effects) for this nested create
|
|
349
300
|
await executeFieldBeforeOperationHooks(
|
|
350
301
|
item,
|
|
351
302
|
resolvedData,
|
|
@@ -355,7 +306,6 @@ async function processNestedCreate(
|
|
|
355
306
|
relatedListName,
|
|
356
307
|
)
|
|
357
308
|
|
|
358
|
-
// 9. List-level beforeOperation for this nested create
|
|
359
309
|
await executeBeforeOperation(relatedListConfig.hooks, {
|
|
360
310
|
listKey: relatedListName,
|
|
361
311
|
operation: 'create',
|
|
@@ -364,26 +314,19 @@ async function processNestedCreate(
|
|
|
364
314
|
context,
|
|
365
315
|
})
|
|
366
316
|
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
// to-many `create: [{A},{B}]` fires once per row, each against its OWN
|
|
371
|
-
// distinct row, and never against a pre-existing sibling.
|
|
317
|
+
// Fires once the parent has persisted. The row is recovered by id-diff and
|
|
318
|
+
// paired to THIS entry by position (see CreatedRowRecovery), so a to-many
|
|
319
|
+
// `create: [{A},{B}]` fires once per row against its own distinct row.
|
|
372
320
|
afterTasks.push({
|
|
373
321
|
fieldName,
|
|
374
322
|
run: async (parentResult) => {
|
|
375
323
|
const createdItem = recovery.rowAt(parentResult, index)
|
|
376
324
|
if (!createdItem) {
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// before-persist hooks have already run; we deliberately SKIP this
|
|
383
|
-
// record's create `afterOperation` rather than fire it with a bogus
|
|
384
|
-
// item. Real Prisma always echoes the `include`d relation, so this
|
|
385
|
-
// skip is reached only by clients/mocks that omit it. `item`
|
|
386
|
-
// correctness is the must-have; a missing row is never fabricated.
|
|
325
|
+
// Could not identify the created row by id-diff — the parent write
|
|
326
|
+
// didn't echo this nested relation (e.g. a client/mock that doesn't
|
|
327
|
+
// honour `include`; real Prisma always does). Deliberately SKIP this
|
|
328
|
+
// record's `afterOperation` rather than fabricate an id-less item —
|
|
329
|
+
// `item` correctness is the must-have, so a missing row is never faked.
|
|
387
330
|
return
|
|
388
331
|
}
|
|
389
332
|
|
|
@@ -406,7 +349,6 @@ async function processNestedCreate(
|
|
|
406
349
|
relatedListName,
|
|
407
350
|
)
|
|
408
351
|
|
|
409
|
-
// Run any deeper nested afterOperation tasks, scoped to the persisted row.
|
|
410
352
|
await runAfterTasks(childAfterTasks, createdItem)
|
|
411
353
|
},
|
|
412
354
|
})
|
|
@@ -421,23 +363,18 @@ async function processNestedCreate(
|
|
|
421
363
|
/**
|
|
422
364
|
* Verify that a single connection target is reachable for the caller.
|
|
423
365
|
*
|
|
424
|
-
* Connecting an existing row
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* nested-relation predicates and boolean combinators (`AND`/`OR`/`some`/
|
|
431
|
-
* `none`/`not`). The existence check is folded into the reachability query so a
|
|
432
|
-
* non-existent id is still denied.
|
|
366
|
+
* Connecting references an existing row rather than modifying it, so — mirroring
|
|
367
|
+
* Keystone — it requires **read/query** access on the target (#578), not update.
|
|
368
|
+
* A filter-result query access is evaluated in the DATABASE via
|
|
369
|
+
* `findFirst({ where: { AND: [connection, accessFilter] } })` rather than in
|
|
370
|
+
* memory, so it correctly handles arbitrary nested-relation predicates and
|
|
371
|
+
* boolean combinators; a non-existent id is folded into the same check.
|
|
433
372
|
*
|
|
434
|
-
* In ADDITION
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
* field's field-level access denies, the connect is denied even when the target
|
|
440
|
-
* row is readable/reachable.
|
|
373
|
+
* In ADDITION, the OWNING relationship field's field-level access (e.g.
|
|
374
|
+
* `Post.author`'s `create`/`update` access) must permit the connect (#588) —
|
|
375
|
+
* the other half Keystone requires: read access on the target AND write access
|
|
376
|
+
* on the owning field. A deny here denies the connect even when the target row
|
|
377
|
+
* is readable/reachable.
|
|
441
378
|
*
|
|
442
379
|
* Sudo bypasses the entire check (handled by the caller).
|
|
443
380
|
*/
|
|
@@ -457,18 +394,10 @@ async function verifyConnectReachable(
|
|
|
457
394
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
458
395
|
const model = (prisma as any)[getDbKey(relatedListName)]
|
|
459
396
|
|
|
460
|
-
// #588
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// `true` under sudo, but the caller already skips this whole function for
|
|
465
|
-
// sudo, so the gate never fires for trusted writes.
|
|
466
|
-
//
|
|
467
|
-
// `item`/`inputData` are the ENCLOSING write's `originalItem`/`inputData` —
|
|
468
|
-
// the SAME values the canonical Phase-5 `filterWritableFields` call passes for
|
|
469
|
-
// this field — so a field-access rule that depends on `item` or `inputData`
|
|
470
|
-
// (e.g. `({ item }) => item.status === 'draft'`) evaluates identically here and
|
|
471
|
-
// at Phase 5, and the two gates cannot diverge into a spurious connect denial.
|
|
397
|
+
// #588 owning-field gate (see docblock above). `item`/`inputData` are the
|
|
398
|
+
// ENCLOSING write's `originalItem`/`inputData` — the same values the canonical
|
|
399
|
+
// Phase-5 `filterWritableFields` call passes for this field — so an
|
|
400
|
+
// item-/inputData-dependent field-access rule can't diverge between the two gates.
|
|
472
401
|
const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
|
|
473
402
|
session: context.session,
|
|
474
403
|
item: enclosingItem,
|
|
@@ -479,20 +408,17 @@ async function verifyConnectReachable(
|
|
|
479
408
|
throw new Error('Access denied: Cannot connect to this item')
|
|
480
409
|
}
|
|
481
410
|
|
|
482
|
-
// Connecting references an existing row; it requires READ (query) access on
|
|
483
|
-
// the target, not update access.
|
|
484
411
|
const queryAccess = relatedListConfig.access?.operation?.query
|
|
485
412
|
const accessResult = await checkAccess(queryAccess, {
|
|
486
413
|
session: context.session,
|
|
487
414
|
context,
|
|
488
415
|
})
|
|
489
416
|
|
|
490
|
-
// Explicit denial.
|
|
491
417
|
if (accessResult === false) {
|
|
492
418
|
throw new Error('Access denied: Cannot connect to this item')
|
|
493
419
|
}
|
|
494
420
|
|
|
495
|
-
// Full access
|
|
421
|
+
// Full access still verifies the row exists, to keep "Item not found" behaviour.
|
|
496
422
|
if (accessResult === true) {
|
|
497
423
|
const item = await model.findUnique({ where: connection })
|
|
498
424
|
if (!item) {
|
|
@@ -501,11 +427,8 @@ async function verifyConnectReachable(
|
|
|
501
427
|
return
|
|
502
428
|
}
|
|
503
429
|
|
|
504
|
-
// Filter result:
|
|
505
|
-
//
|
|
506
|
-
// A non-existent id and an unreachable row both yield no row → denied. This
|
|
507
|
-
// correctly evaluates arbitrary nested-relation predicates and boolean
|
|
508
|
-
// combinators because the database does the matching, not an in-memory walk.
|
|
430
|
+
// Filter result: reachable iff AND-combining the connection with the filter
|
|
431
|
+
// returns a row (see docblock above).
|
|
509
432
|
const reachable = await model.findFirst({
|
|
510
433
|
where: { AND: [connection, accessResult] },
|
|
511
434
|
})
|
|
@@ -534,7 +457,6 @@ async function processNestedConnect(
|
|
|
534
457
|
): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
|
|
535
458
|
const connectionsArray = Array.isArray(connections) ? connections : [connections]
|
|
536
459
|
|
|
537
|
-
// Check read access for each item being connected (skip if sudo mode)
|
|
538
460
|
if (!context._isSudo) {
|
|
539
461
|
for (const connection of connectionsArray) {
|
|
540
462
|
await verifyConnectReachable(
|
|
@@ -582,18 +504,15 @@ async function processNestedUpdate(
|
|
|
582
504
|
|
|
583
505
|
const where = (update as Record<string, unknown>).where as Record<string, unknown>
|
|
584
506
|
|
|
585
|
-
//
|
|
507
|
+
// Fetched here and reused as `originalItem` for afterOperation.
|
|
586
508
|
const originalItem = await model.findUnique({ where })
|
|
587
509
|
|
|
588
510
|
if (!originalItem) {
|
|
589
511
|
throw new Error('Cannot update: Item not found')
|
|
590
512
|
}
|
|
591
513
|
|
|
592
|
-
// The updated row's id is known up front, so the included-result read-back
|
|
593
|
-
// finds this row directly by id.
|
|
594
514
|
const knownId = typeof originalItem.id === 'string' ? (originalItem.id as string) : undefined
|
|
595
515
|
|
|
596
|
-
// Check update access (skip if sudo mode)
|
|
597
516
|
if (!context._isSudo) {
|
|
598
517
|
const updateAccess = relatedListConfig.access?.operation?.update
|
|
599
518
|
const accessResult = await checkAccess(updateAccess, {
|
|
@@ -607,7 +526,6 @@ async function processNestedUpdate(
|
|
|
607
526
|
}
|
|
608
527
|
}
|
|
609
528
|
|
|
610
|
-
// Execute list-level resolveInput hook
|
|
611
529
|
const updateData = (update as Record<string, unknown>).data as Record<string, unknown>
|
|
612
530
|
let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
|
|
613
531
|
listKey: relatedListName,
|
|
@@ -618,7 +536,6 @@ async function processNestedUpdate(
|
|
|
618
536
|
context,
|
|
619
537
|
})
|
|
620
538
|
|
|
621
|
-
// Execute field-level resolveInput hooks
|
|
622
539
|
resolvedData = await executeFieldResolveInputHooks(
|
|
623
540
|
updateData,
|
|
624
541
|
resolvedData,
|
|
@@ -629,7 +546,6 @@ async function processNestedUpdate(
|
|
|
629
546
|
originalItem,
|
|
630
547
|
)
|
|
631
548
|
|
|
632
|
-
// Execute validate hook
|
|
633
549
|
await executeValidate(relatedListConfig.hooks, {
|
|
634
550
|
listKey: relatedListName,
|
|
635
551
|
operation: 'update',
|
|
@@ -639,7 +555,6 @@ async function processNestedUpdate(
|
|
|
639
555
|
context,
|
|
640
556
|
})
|
|
641
557
|
|
|
642
|
-
// Field-level validate hooks
|
|
643
558
|
await executeFieldValidateHooks(
|
|
644
559
|
updateData,
|
|
645
560
|
resolvedData,
|
|
@@ -650,14 +565,13 @@ async function processNestedUpdate(
|
|
|
650
565
|
originalItem,
|
|
651
566
|
)
|
|
652
567
|
|
|
653
|
-
// Field validation (built-in rules)
|
|
654
568
|
const validation = validateFieldRules(resolvedData, relatedListConfig.fields, 'update')
|
|
655
569
|
if (validation.errors.length > 0) {
|
|
656
570
|
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
657
571
|
}
|
|
658
572
|
|
|
659
|
-
// Split multi-column fields into physical columns (#789)
|
|
660
|
-
//
|
|
573
|
+
// Split multi-column fields into physical columns (#789) — must run after
|
|
574
|
+
// validation, which operates on the logical (pre-split) value.
|
|
661
575
|
resolvedData = await splitMultiColumnFields(
|
|
662
576
|
updateData,
|
|
663
577
|
resolvedData,
|
|
@@ -667,7 +581,6 @@ async function processNestedUpdate(
|
|
|
667
581
|
originalItem,
|
|
668
582
|
)
|
|
669
583
|
|
|
670
|
-
// Filter writable fields
|
|
671
584
|
const filtered = await filterWritableFields(
|
|
672
585
|
resolvedData,
|
|
673
586
|
relatedListConfig.fields,
|
|
@@ -680,8 +593,8 @@ async function processNestedUpdate(
|
|
|
680
593
|
},
|
|
681
594
|
)
|
|
682
595
|
|
|
683
|
-
//
|
|
684
|
-
//
|
|
596
|
+
// This nested row is being updated, so its own relations' pre-existing
|
|
597
|
+
// rows are captured from `originalItem` below.
|
|
685
598
|
const { data: nestedData, afterTasks: childAfterTasks } = await processNestedOperations(
|
|
686
599
|
filtered,
|
|
687
600
|
relatedListConfig.fields,
|
|
@@ -690,12 +603,11 @@ async function processNestedUpdate(
|
|
|
690
603
|
'update',
|
|
691
604
|
relatedListName,
|
|
692
605
|
originalItem,
|
|
693
|
-
//
|
|
694
|
-
//
|
|
606
|
+
// Its own update payload is the enclosing inputData for the connect-site
|
|
607
|
+
// owning-field gate (#588).
|
|
695
608
|
updateData,
|
|
696
609
|
)
|
|
697
610
|
|
|
698
|
-
// Field-level beforeOperation (side effects)
|
|
699
611
|
await executeFieldBeforeOperationHooks(
|
|
700
612
|
updateData,
|
|
701
613
|
resolvedData,
|
|
@@ -706,7 +618,6 @@ async function processNestedUpdate(
|
|
|
706
618
|
originalItem,
|
|
707
619
|
)
|
|
708
620
|
|
|
709
|
-
// List-level beforeOperation
|
|
710
621
|
await executeBeforeOperation(relatedListConfig.hooks, {
|
|
711
622
|
listKey: relatedListName,
|
|
712
623
|
operation: 'update',
|
|
@@ -716,8 +627,7 @@ async function processNestedUpdate(
|
|
|
716
627
|
context,
|
|
717
628
|
})
|
|
718
629
|
|
|
719
|
-
//
|
|
720
|
-
// is recovered from the parent's included relation by its known id.
|
|
630
|
+
// Fires after the parent persist; the row is recovered by its known id.
|
|
721
631
|
afterTasks.push({
|
|
722
632
|
fieldName,
|
|
723
633
|
run: async (parentResult) => {
|
|
@@ -798,7 +708,6 @@ async function processNestedDelete(
|
|
|
798
708
|
throw new Error('Cannot delete: Item not found')
|
|
799
709
|
}
|
|
800
710
|
|
|
801
|
-
// Check delete access (skip if sudo mode)
|
|
802
711
|
if (!context._isSudo) {
|
|
803
712
|
const deleteAccess = relatedListConfig.access?.operation?.delete
|
|
804
713
|
const accessResult = await checkAccess(deleteAccess, {
|
|
@@ -812,7 +721,6 @@ async function processNestedDelete(
|
|
|
812
721
|
}
|
|
813
722
|
}
|
|
814
723
|
|
|
815
|
-
// List-level validate (delete)
|
|
816
724
|
await executeValidate(relatedListConfig.hooks, {
|
|
817
725
|
listKey: relatedListName,
|
|
818
726
|
operation: 'delete',
|
|
@@ -820,7 +728,6 @@ async function processNestedDelete(
|
|
|
820
728
|
context,
|
|
821
729
|
})
|
|
822
730
|
|
|
823
|
-
// Field-level validate (delete)
|
|
824
731
|
await executeFieldValidateHooks(
|
|
825
732
|
undefined,
|
|
826
733
|
undefined,
|
|
@@ -831,7 +738,6 @@ async function processNestedDelete(
|
|
|
831
738
|
originalItem,
|
|
832
739
|
)
|
|
833
740
|
|
|
834
|
-
// Field-level beforeOperation (delete)
|
|
835
741
|
await executeFieldBeforeOperationHooks(
|
|
836
742
|
{},
|
|
837
743
|
{},
|
|
@@ -842,7 +748,6 @@ async function processNestedDelete(
|
|
|
842
748
|
originalItem,
|
|
843
749
|
)
|
|
844
750
|
|
|
845
|
-
// List-level beforeOperation (delete)
|
|
846
751
|
await executeBeforeOperation(relatedListConfig.hooks, {
|
|
847
752
|
listKey: relatedListName,
|
|
848
753
|
operation: 'delete',
|
|
@@ -850,8 +755,8 @@ async function processNestedDelete(
|
|
|
850
755
|
context,
|
|
851
756
|
})
|
|
852
757
|
|
|
853
|
-
//
|
|
854
|
-
//
|
|
758
|
+
// The row is gone after persist, so originalItem is the authoritative
|
|
759
|
+
// record passed to the after-hooks below.
|
|
855
760
|
afterTasks.push({
|
|
856
761
|
fieldName: '',
|
|
857
762
|
run: async () => {
|
|
@@ -880,9 +785,6 @@ async function processNestedDelete(
|
|
|
880
785
|
return deletes
|
|
881
786
|
}
|
|
882
787
|
|
|
883
|
-
/**
|
|
884
|
-
* Process nested connectOrCreate operations
|
|
885
|
-
*/
|
|
886
788
|
async function processNestedConnectOrCreate(
|
|
887
789
|
operations: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
888
790
|
fieldName: string,
|
|
@@ -905,15 +807,11 @@ async function processNestedConnectOrCreate(
|
|
|
905
807
|
operationsArray.map(async (op) => {
|
|
906
808
|
const opRecord = op as Record<string, unknown>
|
|
907
809
|
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
// DB reachability for filter results, PLUS the owning relationship field's
|
|
914
|
-
// field-level access (#588). When the row does not exist we fall through to
|
|
915
|
-
// create. We must NOT swallow an access-denied error: only the genuine
|
|
916
|
-
// "row absent" case may fall back to create.
|
|
810
|
+
// When the target row exists, connectOrCreate applies the same connect
|
|
811
|
+
// semantics as processNestedConnect (read access + #588 owning-field gate,
|
|
812
|
+
// both evaluated via DB reachability); otherwise it falls through to
|
|
813
|
+
// create. An access-denied error must NOT be swallowed into that fallback
|
|
814
|
+
// — only a genuine "row absent" may fall back to create.
|
|
917
815
|
let rowExists = false
|
|
918
816
|
if (!context._isSudo) {
|
|
919
817
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -922,18 +820,9 @@ async function processNestedConnectOrCreate(
|
|
|
922
820
|
|
|
923
821
|
const existingItem = await model.findUnique({ where })
|
|
924
822
|
|
|
925
|
-
// Only enforce connect access when the row actually exists; otherwise
|
|
926
|
-
// the create branch is used.
|
|
927
823
|
if (existingItem) {
|
|
928
824
|
rowExists = true
|
|
929
825
|
|
|
930
|
-
// #588 — gate the connect branch by the OWNING relationship field's
|
|
931
|
-
// field-level access, identical to processNestedConnect. A deny here
|
|
932
|
-
// denies the connect even if the target row is readable/reachable.
|
|
933
|
-
// `item`/`inputData` are the ENCLOSING write's `originalItem`/
|
|
934
|
-
// `inputData` (the same values Phase-5 `filterWritableFields` passes),
|
|
935
|
-
// so item-/inputData-dependent field rules cannot diverge between the
|
|
936
|
-
// two gates.
|
|
937
826
|
const owningFieldAllowed = await checkFieldAccess(owningFieldAccess, enclosingOperation, {
|
|
938
827
|
session: context.session,
|
|
939
828
|
item: enclosingItem,
|
|
@@ -955,8 +844,6 @@ async function processNestedConnectOrCreate(
|
|
|
955
844
|
throw new Error('Access denied: Cannot connect to existing item')
|
|
956
845
|
}
|
|
957
846
|
|
|
958
|
-
// Filter result: confirm the existing row is reachable under the
|
|
959
|
-
// access filter via DB reachability (handles nested/boolean filters).
|
|
960
847
|
if (accessResult !== true) {
|
|
961
848
|
const reachable = await model.findFirst({
|
|
962
849
|
where: { AND: [where, accessResult] },
|
|
@@ -969,11 +856,10 @@ async function processNestedConnectOrCreate(
|
|
|
969
856
|
}
|
|
970
857
|
}
|
|
971
858
|
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
// create pipeline run its hooks (sudo bypasses access only, not hooks).
|
|
859
|
+
// Only register an afterOperation task when the create branch will
|
|
860
|
+
// actually run (row absent), so a pure connect doesn't fire create hooks.
|
|
861
|
+
// Under sudo we can't tell statically, so the create pipeline always runs
|
|
862
|
+
// its hooks there (sudo bypasses access only, not hooks).
|
|
977
863
|
const runCreateHooks = context._isSudo || !rowExists
|
|
978
864
|
const createAfterTasks: AfterTask[] = runCreateHooks ? afterTasks : []
|
|
979
865
|
const processedCreate = await processNestedCreate(
|
|
@@ -1011,29 +897,20 @@ interface NestedOpHandlerArgs {
|
|
|
1011
897
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
1012
898
|
relatedListConfig: ListConfig<any>
|
|
1013
899
|
/**
|
|
1014
|
-
* Field-level `access` of the OWNING relationship field
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
900
|
+
* Field-level `access` of the OWNING relationship field (e.g. `Post.author`).
|
|
901
|
+
* Gates connect/connectOrCreate by the owning field's create/update access —
|
|
902
|
+
* see the #588 gate in {@link verifyConnectReachable}.
|
|
1017
903
|
*/
|
|
1018
904
|
owningFieldAccess: FieldAccess | undefined
|
|
1019
|
-
/**
|
|
1020
|
-
* The enclosing write's operation (`create`/`update`), used as the field-access
|
|
1021
|
-
* operation for the owning-field connect gate (#588).
|
|
1022
|
-
*/
|
|
905
|
+
/** The enclosing write's operation, used as the owning-field gate's field-access operation. */
|
|
1023
906
|
enclosingOperation: 'create' | 'update'
|
|
1024
907
|
/**
|
|
1025
|
-
* The enclosing write's
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
* canonical Phase-5 `filterWritableFields` call and the two cannot diverge
|
|
1029
|
-
* (#588 finding).
|
|
908
|
+
* The enclosing write's `originalItem` (`undefined` for an enclosing create).
|
|
909
|
+
* Passed to the owning-field gate so it matches the canonical Phase-5
|
|
910
|
+
* `filterWritableFields` call and the two can't diverge (#588).
|
|
1030
911
|
*/
|
|
1031
912
|
enclosingItem: Record<string, unknown> | undefined
|
|
1032
|
-
/**
|
|
1033
|
-
* The enclosing write's input data. Threaded into the connect-site owning-field
|
|
1034
|
-
* gate so it evaluates `inputData` exactly like the canonical Phase-5
|
|
1035
|
-
* `filterWritableFields` call (#588 finding).
|
|
1036
|
-
*/
|
|
913
|
+
/** The enclosing write's input data, passed to the owning-field gate for the same reason as `enclosingItem`. */
|
|
1037
914
|
enclosingInputData: Record<string, unknown> | undefined
|
|
1038
915
|
context: AccessContext
|
|
1039
916
|
config: OpenSaasConfig
|
|
@@ -1307,10 +1184,8 @@ export async function processNestedOperations(
|
|
|
1307
1184
|
operation: 'create' | 'update',
|
|
1308
1185
|
parentListName: string,
|
|
1309
1186
|
parentOriginalItem: Record<string, unknown> | undefined,
|
|
1310
|
-
// The enclosing write's
|
|
1311
|
-
//
|
|
1312
|
-
// finding) so item-/inputData-dependent field-access rules cannot diverge between
|
|
1313
|
-
// Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
|
|
1187
|
+
// The enclosing write's inputData, for the #588 owning-field gate (see
|
|
1188
|
+
// verifyConnectReachable). `undefined` is tolerated (defaults to `{}`).
|
|
1314
1189
|
parentInputData: Record<string, unknown> | undefined = undefined,
|
|
1315
1190
|
): Promise<NestedOpsResult> {
|
|
1316
1191
|
const afterTasks: AfterTask[] = []
|
|
@@ -1321,13 +1196,11 @@ export async function processNestedOperations(
|
|
|
1321
1196
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
1322
1197
|
const fieldConfig = fieldConfigs[fieldName]
|
|
1323
1198
|
|
|
1324
|
-
// If not a relationship field or no value, pass through
|
|
1325
1199
|
if (!isRelationshipField(fieldConfig) || value === null || value === undefined) {
|
|
1326
1200
|
processed[fieldName] = value
|
|
1327
1201
|
continue
|
|
1328
1202
|
}
|
|
1329
1203
|
|
|
1330
|
-
// Get related list config
|
|
1331
1204
|
const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
|
|
1332
1205
|
const relatedConfig = getRelatedListConfig(relationshipField.ref, config)
|
|
1333
1206
|
if (!relatedConfig) {
|
|
@@ -1336,13 +1209,10 @@ export async function processNestedOperations(
|
|
|
1336
1209
|
}
|
|
1337
1210
|
|
|
1338
1211
|
const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig
|
|
1339
|
-
// Sanity: ensure the resolved list name matches the config identity.
|
|
1340
1212
|
const resolvedListName = relatedListName || findListName(relatedListConfig, config)
|
|
1341
1213
|
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
// connect/connectOrCreate handlers can gate connects by this field's
|
|
1345
|
-
// create/update access, in addition to the target's read access.
|
|
1214
|
+
// The owning relationship field's field-level access, for the #588 gate
|
|
1215
|
+
// in verifyConnectReachable.
|
|
1346
1216
|
const owningFieldAccess = fieldConfig.access
|
|
1347
1217
|
|
|
1348
1218
|
processed[fieldName] = await processFieldNestedOps(
|
|
@@ -1353,10 +1223,8 @@ export async function processNestedOperations(
|
|
|
1353
1223
|
relatedListConfig,
|
|
1354
1224
|
owningFieldAccess,
|
|
1355
1225
|
enclosingOperation: operation,
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
// the connect-site owning-field gate evaluates item-/inputData-dependent
|
|
1359
|
-
// rules identically and cannot diverge into a spurious connect denial (#588).
|
|
1226
|
+
// Same values Phase-5 `filterWritableFields` passes for this field, so
|
|
1227
|
+
// the #588 owning-field gate can't diverge from it.
|
|
1360
1228
|
enclosingItem: parentOriginalItem,
|
|
1361
1229
|
enclosingInputData: parentInputData ?? {},
|
|
1362
1230
|
context,
|