@opensaas/stack-core 0.38.0 → 0.39.1
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 +125 -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
|
@@ -30,18 +30,14 @@ import { buildDbDelegate } from '../context/index.js'
|
|
|
30
30
|
* level this way; a bare or `include`-based read, and any relation reached
|
|
31
31
|
* purely to satisfy a `needs` declaration, still compute every field, as
|
|
32
32
|
* before. A field the read is not going to return does no work at all —
|
|
33
|
-
* neither its read-access evaluation nor its hook.
|
|
33
|
+
* neither its read-access evaluation nor its hook. The projection-aware skip
|
|
34
|
+
* below shows this rule at each of the two places it applies.
|
|
34
35
|
*
|
|
35
36
|
* Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
|
|
36
37
|
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
37
38
|
* glossary in `CONTEXT.md`.
|
|
38
39
|
*/
|
|
39
40
|
|
|
40
|
-
/**
|
|
41
|
-
* Runtime type for resolveOutput hooks
|
|
42
|
-
* Used when we need to call hooks generically without knowing the specific field type
|
|
43
|
-
* Supports both sync and async implementations
|
|
44
|
-
*/
|
|
45
41
|
type ResolveOutputHookRuntime = (args: {
|
|
46
42
|
operation: 'query'
|
|
47
43
|
value: unknown
|
|
@@ -123,7 +119,6 @@ async function resolveReadableFieldValue(params: {
|
|
|
123
119
|
}): Promise<{ readable: false } | { readable: true; value: unknown }> {
|
|
124
120
|
const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args, config } = params
|
|
125
121
|
|
|
126
|
-
// Check field access (checkFieldAccess already handles sudo mode)
|
|
127
122
|
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
128
123
|
...args,
|
|
129
124
|
item: accessItem,
|
|
@@ -133,10 +128,9 @@ async function resolveReadableFieldValue(params: {
|
|
|
133
128
|
return { readable: false }
|
|
134
129
|
}
|
|
135
130
|
|
|
136
|
-
// Apply resolveOutput hook if present
|
|
137
131
|
if (fieldConfig?.hooks?.resolveOutput && listKey) {
|
|
138
|
-
//
|
|
139
|
-
//
|
|
132
|
+
// The hook is erased to this runtime shape here; at the actual call it
|
|
133
|
+
// receives the value typed for its own field.
|
|
140
134
|
const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
|
|
141
135
|
const link = { listKey, fieldKey: fieldName }
|
|
142
136
|
const chain = args.context._resolveOutputChain
|
|
@@ -164,7 +158,6 @@ async function resolveReadableFieldValue(params: {
|
|
|
164
158
|
return { readable: false }
|
|
165
159
|
}
|
|
166
160
|
|
|
167
|
-
// Use Promise.resolve() to handle both sync and async hooks
|
|
168
161
|
const resolved = await Promise.resolve(
|
|
169
162
|
hook({
|
|
170
163
|
value,
|
|
@@ -181,10 +174,6 @@ async function resolveReadableFieldValue(params: {
|
|
|
181
174
|
return { readable: true, value }
|
|
182
175
|
}
|
|
183
176
|
|
|
184
|
-
/**
|
|
185
|
-
* Filter fields from an object based on read access
|
|
186
|
-
* Recursively applies access control to nested relationships
|
|
187
|
-
*/
|
|
188
177
|
export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
189
178
|
item: T,
|
|
190
179
|
fieldConfigs: Record<string, FieldConfig>,
|
|
@@ -201,12 +190,8 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
201
190
|
// returned — after resolveOutput has had a chance to read them — so a
|
|
202
191
|
// declared dependency never widens what the caller receives.
|
|
203
192
|
declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
|
|
204
|
-
// The fragment scope this level was reached under (ADR-0027
|
|
205
|
-
// tree one level down for each nested relation.
|
|
206
|
-
// and what a bare/`include`-based read passes at every level — means
|
|
207
|
-
// unrestricted: every field on the list is computed, unchanged from
|
|
208
|
-
// before ADR-0027. Only a `query` fragment's own field selection ever
|
|
209
|
-
// restricts a level.
|
|
193
|
+
// The fragment scope this level was reached under (ADR-0027, see module doc
|
|
194
|
+
// above), and the same tree one level down for each nested relation.
|
|
210
195
|
selection?: FieldSelectionScope,
|
|
211
196
|
): Promise<Partial<T>> {
|
|
212
197
|
const filtered: Record<string, unknown> = {}
|
|
@@ -246,26 +231,22 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
246
231
|
for (const [fieldName, value] of Object.entries(workingItem)) {
|
|
247
232
|
const fieldConfig = fieldConfigs[fieldName]
|
|
248
233
|
|
|
249
|
-
// Always include id, createdAt, updatedAt
|
|
250
234
|
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
251
235
|
filtered[fieldName] = value
|
|
252
236
|
continue
|
|
253
237
|
}
|
|
254
238
|
|
|
255
|
-
// Projection-aware skip (ADR-0027
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// read
|
|
259
|
-
// by a fragment's own field selection; a bare/`include`-based read, and a
|
|
260
|
-
// relation reached only to satisfy another field's `needs`, pass no
|
|
261
|
-
// selection at all and compute every field here, unchanged.
|
|
239
|
+
// Projection-aware skip (ADR-0027, see module doc above): a field this
|
|
240
|
+
// level's fragment did not select gets no read-access check, no
|
|
241
|
+
// resolveOutput, and no recursion into a relation — none of that work
|
|
242
|
+
// happens for a value the read isn't going to return.
|
|
262
243
|
if (selection?.fields && !selection.fields.has(fieldName)) {
|
|
263
244
|
continue
|
|
264
245
|
}
|
|
265
246
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
247
|
+
// Row/relation-level access is already scoped at the DB level via
|
|
248
|
+
// buildAccessScopedInclude; this only handles field-level access (hiding
|
|
249
|
+
// sensitive fields).
|
|
269
250
|
//
|
|
270
251
|
// Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
|
|
271
252
|
// what gets FETCHED (a caller include past its depth cap is now a denial,
|
|
@@ -283,7 +264,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
283
264
|
value !== null &&
|
|
284
265
|
value !== undefined
|
|
285
266
|
) {
|
|
286
|
-
// Gate the relationship on read access before recursing.
|
|
287
267
|
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
288
268
|
...args,
|
|
289
269
|
item: workingItem,
|
|
@@ -308,8 +288,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
308
288
|
const nestedSelection = selection?.nested[fieldName]
|
|
309
289
|
|
|
310
290
|
if (relatedConfig) {
|
|
311
|
-
// For many relationships (arrays) - recursively filter fields in each item
|
|
312
|
-
// The recursive call already handles applying resolveOutput hooks
|
|
313
291
|
if (Array.isArray(value)) {
|
|
314
292
|
filtered[fieldName] = await Promise.all(
|
|
315
293
|
value.map((relatedItem) =>
|
|
@@ -325,10 +303,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
325
303
|
),
|
|
326
304
|
),
|
|
327
305
|
)
|
|
328
|
-
}
|
|
329
|
-
// For single relationships (objects) - recursively filter fields
|
|
330
|
-
// The recursive call already handles applying resolveOutput hooks
|
|
331
|
-
else if (typeof value === 'object') {
|
|
306
|
+
} else if (typeof value === 'object') {
|
|
332
307
|
filtered[fieldName] = await filterReadableFields(
|
|
333
308
|
value as Record<string, unknown>,
|
|
334
309
|
relatedConfig.listConfig.fields,
|
|
@@ -341,14 +316,13 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
341
316
|
)
|
|
342
317
|
}
|
|
343
318
|
} else {
|
|
344
|
-
// Related config not found, include the value as-is
|
|
345
319
|
filtered[fieldName] = value
|
|
346
320
|
}
|
|
347
321
|
continue
|
|
348
322
|
}
|
|
349
323
|
|
|
350
|
-
// Non-relationship field
|
|
351
|
-
//
|
|
324
|
+
// Non-relationship field, or a relationship field whose value is not
|
|
325
|
+
// includable (null/undefined) — falls through to the shared helper.
|
|
352
326
|
const result = await resolveReadableFieldValue({
|
|
353
327
|
fieldConfig,
|
|
354
328
|
fieldName,
|
|
@@ -394,15 +368,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
394
368
|
delete computedFieldItem[key]
|
|
395
369
|
}
|
|
396
370
|
|
|
397
|
-
// Process virtual fields - compute values from other fields
|
|
398
|
-
// Virtual fields don't exist in the database result, so we need to compute them separately
|
|
399
371
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
400
|
-
// Skip if already processed (from database result)
|
|
401
372
|
if (fieldName in filtered) {
|
|
402
373
|
continue
|
|
403
374
|
}
|
|
404
375
|
|
|
405
|
-
// Only process virtual fields
|
|
406
376
|
if (!fieldConfig.virtual) {
|
|
407
377
|
continue
|
|
408
378
|
}
|
|
@@ -423,7 +393,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
423
393
|
continue
|
|
424
394
|
}
|
|
425
395
|
|
|
426
|
-
// Check read access and compute the value via the shared helper.
|
|
427
396
|
const result = await resolveReadableFieldValue({
|
|
428
397
|
fieldConfig,
|
|
429
398
|
fieldName,
|
package/src/access/index.ts
CHANGED
|
@@ -20,9 +20,23 @@ export {
|
|
|
20
20
|
getRelatedListConfig,
|
|
21
21
|
} from './engine.js'
|
|
22
22
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
checkFieldAccess,
|
|
25
|
+
filterWritableFields,
|
|
26
|
+
isFieldReadableForPredicate,
|
|
27
|
+
} from './field-access.js'
|
|
28
|
+
// Read-path key validation — the `findMany`/`count` counterpart to the write
|
|
29
|
+
// path's #564 undeclared-key reject.
|
|
30
|
+
export { validateQueryKeys } from './query-validation.js'
|
|
31
|
+
// Read-path field-level access on `where`/`orderBy` keys — a field the
|
|
32
|
+
// session cannot read cannot be named in a predicate either (#915).
|
|
33
|
+
export { validateQueryFieldReadAccess } from './query-validation.js'
|
|
24
34
|
// Phase 1 — Access Filter (pre-query row/relation scoping).
|
|
25
|
-
export {
|
|
35
|
+
export {
|
|
36
|
+
buildAccessScopedInclude,
|
|
37
|
+
buildAccessScopedWhere,
|
|
38
|
+
stripVirtualFieldsFromInclude,
|
|
39
|
+
} from './access-filter.js'
|
|
26
40
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
27
41
|
export { filterReadableFields } from './field-visibility.js'
|
|
28
42
|
// Declared Dependencies — folding `needs` into an include without widening
|
|
@@ -37,3 +51,7 @@ export type { DeclaredOnlyTree } from './declared-dependencies.js'
|
|
|
37
51
|
export { AccessScopeDepthExceededError } from './errors.js'
|
|
38
52
|
// Thrown when a resolveOutput hook's own resolve chain cycles back into itself.
|
|
39
53
|
export { ResolveOutputCycleError } from './errors.js'
|
|
54
|
+
// Thrown when a field-level access control function returns a non-boolean result.
|
|
55
|
+
export { InvalidFieldAccessResultError } from './errors.js'
|
|
56
|
+
// Thrown when a relation filter's related list denies query access outright (#916).
|
|
57
|
+
export { RelationFilterAccessDeniedError } from './errors.js'
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { ListConfig, OpenSaasConfig } from '../config/types.js'
|
|
2
|
+
import type { Session, AccessContext } from './types.js'
|
|
3
|
+
import { getRelatedListConfig } from './engine.js'
|
|
4
|
+
import { isFieldReadableForPredicate } from './field-access.js'
|
|
5
|
+
import { ValidationError } from '../hooks/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* #912 — read-path key validation.
|
|
9
|
+
*
|
|
10
|
+
* The write path settled #564: an undeclared key in `data` throws, because the
|
|
11
|
+
* generated Prisma model has strictly more fields than the config declares (most
|
|
12
|
+
* notably back-relations — Prisma emits one for every inbound foreign key, whether
|
|
13
|
+
* or not the list config declares the reverse relationship). Reads had no
|
|
14
|
+
* equivalent — a caller's `where`/`orderBy` reached Prisma unchanged, so an
|
|
15
|
+
* anonymous caller could filter or order by a relation the config never exposed.
|
|
16
|
+
*
|
|
17
|
+
* This module is the read-path counterpart: every key named in a caller's `where`
|
|
18
|
+
* or `orderBy` is resolved against the list config before the query runs. A key
|
|
19
|
+
* with no entry in the config throws, naming the list and the key. `sudo` is the
|
|
20
|
+
* single trusted bypass, mirroring the write path.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT walked: the access filter produced by the list's own `query`
|
|
23
|
+
* access control. That filter is trusted config authored by the same person who
|
|
24
|
+
* declares the fields — walking it would make this an access-control decision
|
|
25
|
+
* (that's #915/#916), not the key-existence seam this ticket establishes.
|
|
26
|
+
*
|
|
27
|
+
* `validateQueryFieldReadAccess` below is that access-control decision for
|
|
28
|
+
* #915: it re-walks `where`/`orderBy` (reusing `resolveQueryField`) and checks
|
|
29
|
+
* each resolved field's `read` access via the canonical evaluator, so a field
|
|
30
|
+
* the session cannot read cannot be named in a predicate either. It runs
|
|
31
|
+
* strictly after this module's key-existence check — an undeclared key is
|
|
32
|
+
* #912's rejection, not a field-access decision — and stays scoped to the
|
|
33
|
+
* CURRENT list, deliberately not recursing into a related list's fields
|
|
34
|
+
* nested inside a relation filter itself (that recursion is #916's job, not
|
|
35
|
+
* this module's — see below).
|
|
36
|
+
*
|
|
37
|
+
* #916 — the relation-filter counterpart — scopes a relation filter itself
|
|
38
|
+
* (`some`/`every`/`none`/`is`/`isNot`) by the RELATED list's own `query`
|
|
39
|
+
* access, folding it into the nested clause exactly like
|
|
40
|
+
* `buildAccessScopedInclude` folds it into `include` (`access-filter.ts`).
|
|
41
|
+
* It reuses this module's shape-recognition (`resolveQueryField`,
|
|
42
|
+
* `LOGICAL_OPERATORS`, `RELATION_QUANTIFIERS`, exported below) and calls
|
|
43
|
+
* `walkWhereReadAccess` once per hop — against the RELATED list's own
|
|
44
|
+
* config — for the field-read half of the same job this module's
|
|
45
|
+
* `validateQueryFieldReadAccess` already does for the CURRENT list. There is
|
|
46
|
+
* deliberately no second copy of either the shape-recognition or the
|
|
47
|
+
* field-read check: `access-filter.ts` supplies the RELATED list at each
|
|
48
|
+
* hop and calls back into the same primitives this module already owns.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// Prisma's logical combinators for a WHERE clause — never field names.
|
|
52
|
+
export const LOGICAL_OPERATORS = new Set(['AND', 'OR', 'NOT'])
|
|
53
|
+
|
|
54
|
+
// Prisma's relation quantifiers. The value nested under one of these is itself a
|
|
55
|
+
// WHERE clause for the RELATED list, and is walked against that list's fields.
|
|
56
|
+
export const RELATION_QUANTIFIERS = new Set(['some', 'every', 'none', 'is', 'isNot'])
|
|
57
|
+
|
|
58
|
+
// Always present, never declared in a list's `fields` — the write path
|
|
59
|
+
// (`filterWritableFields`) excludes the same three names from `fieldConfigs`.
|
|
60
|
+
const SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt'])
|
|
61
|
+
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- field configs are heterogeneous across field types
|
|
63
|
+
type FieldConfigMap = Record<string, any>
|
|
64
|
+
|
|
65
|
+
export interface ResolvedQueryField {
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- field configs are heterogeneous across field types
|
|
67
|
+
fieldConfig: any
|
|
68
|
+
isRelationship: boolean
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a `where`/`orderBy` key against a list's declared fields.
|
|
73
|
+
*
|
|
74
|
+
* A key is valid when it is:
|
|
75
|
+
* - a system field (`id`, `createdAt`, `updatedAt`) — always present, and never
|
|
76
|
+
* declared in `fields` (the write path excludes them from `fieldConfigs` the
|
|
77
|
+
* same way), or
|
|
78
|
+
* - a field declared directly in the list config, or
|
|
79
|
+
* - the foreign-key scalar a to-one `relationship` field implies (e.g. `authorId`
|
|
80
|
+
* for `author: relationship(...)`) — the config never names this column
|
|
81
|
+
* directly, but Prisma always generates it, and the write path
|
|
82
|
+
* (`filterWritableFields`) grants it the same pass, or
|
|
83
|
+
* - a raw per-part column a multi-column field's `splitColumns` contributes (e.g.
|
|
84
|
+
* storage `image()`/`file()` in Keystone-parity mode) — undeclared by design,
|
|
85
|
+
* mirroring the write path's `splitColumnOwners` allowance (#568/#789).
|
|
86
|
+
*
|
|
87
|
+
* Anything else — most importantly a Prisma-generated back-relation the config
|
|
88
|
+
* never declares — resolves to `undefined` and is rejected by the caller.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveQueryField(
|
|
91
|
+
key: string,
|
|
92
|
+
fields: FieldConfigMap,
|
|
93
|
+
): ResolvedQueryField | undefined {
|
|
94
|
+
if (SYSTEM_FIELDS.has(key)) {
|
|
95
|
+
return { fieldConfig: undefined, isRelationship: false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fieldConfig = fields[key]
|
|
99
|
+
if (fieldConfig) {
|
|
100
|
+
return { fieldConfig, isRelationship: fieldConfig.type === 'relationship' }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (key.endsWith('Id')) {
|
|
104
|
+
const baseField = fields[key.slice(0, -2)]
|
|
105
|
+
if (baseField && baseField.type === 'relationship' && !baseField.many) {
|
|
106
|
+
return { fieldConfig: baseField, isRelationship: false }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [ownerName, owner] of Object.entries(fields)) {
|
|
111
|
+
if (owner && typeof owner.getColumnNames === 'function') {
|
|
112
|
+
const columns: string[] = owner.getColumnNames(ownerName)
|
|
113
|
+
if (columns.includes(key)) {
|
|
114
|
+
return { fieldConfig: owner, isRelationship: false }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function rejectUndeclaredKey(listName: string, key: string, kind: 'where' | 'orderBy'): never {
|
|
123
|
+
throw new ValidationError([
|
|
124
|
+
`Cannot query "${listName}" — "${key}" is not a field of this list. ` +
|
|
125
|
+
`Undeclared ${kind} keys are rejected (use sudo to bypass).`,
|
|
126
|
+
])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function walkWhere(
|
|
130
|
+
where: unknown,
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
132
|
+
listConfig: ListConfig<any>,
|
|
133
|
+
listName: string,
|
|
134
|
+
config: OpenSaasConfig,
|
|
135
|
+
isSudo: boolean,
|
|
136
|
+
): void {
|
|
137
|
+
if (where === null || typeof where !== 'object') return
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(where)) {
|
|
140
|
+
for (const entry of where) walkWhere(entry, listConfig, listName, config, isSudo)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
|
|
145
|
+
if (LOGICAL_OPERATORS.has(key)) {
|
|
146
|
+
walkWhere(value, listConfig, listName, config, isSudo)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const resolved = resolveQueryField(key, listConfig.fields)
|
|
151
|
+
if (!resolved) {
|
|
152
|
+
if (isSudo) continue
|
|
153
|
+
rejectUndeclaredKey(listName, key, 'where')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Scalar field filters use Prisma's own operator vocabulary (`equals`,
|
|
157
|
+
// `contains`, `in`, …) and never nest another field name — trusted as-is,
|
|
158
|
+
// no further walk needed. Only a relationship field's filter nests a
|
|
159
|
+
// WHERE clause for another list.
|
|
160
|
+
if (
|
|
161
|
+
resolved.isRelationship &&
|
|
162
|
+
value !== null &&
|
|
163
|
+
typeof value === 'object' &&
|
|
164
|
+
!Array.isArray(value)
|
|
165
|
+
) {
|
|
166
|
+
const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
|
|
167
|
+
if (!related) continue
|
|
168
|
+
|
|
169
|
+
const relationEntries = Object.entries(value as Record<string, unknown>)
|
|
170
|
+
const hasQuantifier = relationEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
|
|
171
|
+
|
|
172
|
+
if (hasQuantifier) {
|
|
173
|
+
// Wrapped form: `{ author: { is: {...} } }` / `{ posts: { some: {...} } }`.
|
|
174
|
+
// Only the quantifier's own value is a nested WHERE clause for the
|
|
175
|
+
// related list.
|
|
176
|
+
for (const [quantifier, quantifierValue] of relationEntries) {
|
|
177
|
+
if (RELATION_QUANTIFIERS.has(quantifier)) {
|
|
178
|
+
walkWhere(quantifierValue, related.listConfig, related.listName, config, isSudo)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Direct-nesting form: Prisma's documented default for a to-one
|
|
183
|
+
// relation filter nests the related list's own fields with no `is`
|
|
184
|
+
// wrapper at all (`{ author: { email: { contains: '...' } } }`). The
|
|
185
|
+
// whole value object IS the nested WHERE clause here — walk it
|
|
186
|
+
// directly, or an undeclared key reached exactly this way (one hop
|
|
187
|
+
// through a to-one relation) would pass through unchecked.
|
|
188
|
+
walkWhere(value, related.listConfig, related.listName, config, isSudo)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function walkOrderBy(
|
|
195
|
+
orderBy: unknown,
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
197
|
+
listConfig: ListConfig<any>,
|
|
198
|
+
listName: string,
|
|
199
|
+
config: OpenSaasConfig,
|
|
200
|
+
isSudo: boolean,
|
|
201
|
+
): void {
|
|
202
|
+
if (orderBy === null || typeof orderBy !== 'object') return
|
|
203
|
+
|
|
204
|
+
if (Array.isArray(orderBy)) {
|
|
205
|
+
for (const entry of orderBy) walkOrderBy(entry, listConfig, listName, config, isSudo)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const [key, value] of Object.entries(orderBy as Record<string, unknown>)) {
|
|
210
|
+
const resolved = resolveQueryField(key, listConfig.fields)
|
|
211
|
+
if (!resolved) {
|
|
212
|
+
if (isSudo) continue
|
|
213
|
+
rejectUndeclaredKey(listName, key, 'orderBy')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (resolved.isRelationship && value !== null && typeof value === 'object') {
|
|
217
|
+
// `{ relation: { _count: 'asc' } }` orders by an aggregate — no nested
|
|
218
|
+
// field name to resolve. `{ relation: { name: 'asc' } }` orders by a
|
|
219
|
+
// field on a to-one related list — walk it against that list's fields.
|
|
220
|
+
if ('_count' in (value as Record<string, unknown>)) continue
|
|
221
|
+
|
|
222
|
+
const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
|
|
223
|
+
if (related) walkOrderBy(value, related.listConfig, related.listName, config, isSudo)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Validate a caller-supplied `where`/`orderBy` against the list config,
|
|
230
|
+
* recursing into logical operators (`AND`/`OR`/`NOT`) and relation filters.
|
|
231
|
+
* Throws a `ValidationError` naming the list and the offending key on the
|
|
232
|
+
* first undeclared key found. `isSudo` bypasses the check entirely, matching
|
|
233
|
+
* the write path's `sudo` escape hatch.
|
|
234
|
+
*/
|
|
235
|
+
export function validateQueryKeys(args: {
|
|
236
|
+
where?: unknown
|
|
237
|
+
orderBy?: unknown
|
|
238
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
239
|
+
listConfig: ListConfig<any>
|
|
240
|
+
listName: string
|
|
241
|
+
config: OpenSaasConfig
|
|
242
|
+
isSudo: boolean
|
|
243
|
+
}): void {
|
|
244
|
+
const { where, orderBy, listConfig, listName, config, isSudo } = args
|
|
245
|
+
if (where !== undefined) walkWhere(where, listConfig, listName, config, isSudo)
|
|
246
|
+
if (orderBy !== undefined) walkOrderBy(orderBy, listConfig, listName, config, isSudo)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* #915 — the predicate-time counterpart to `checkFieldAccess`'s post-query
|
|
251
|
+
* check: reject a `where`/`orderBy` key that names a field the session cannot
|
|
252
|
+
* read, BEFORE the query runs. See this module's top doc comment for how this
|
|
253
|
+
* relates to `validateQueryKeys` (#912) and `isFieldReadableForPredicate`'s
|
|
254
|
+
* doc (in `field-access.ts`) for how a row-dependent `read` rule is handled.
|
|
255
|
+
*
|
|
256
|
+
* A key `resolveQueryField` cannot resolve is skipped here — #912 has already
|
|
257
|
+
* rejected it (or, under `sudo`, deliberately let it through) by the time
|
|
258
|
+
* this runs. A system field (`id`/`createdAt`/`updatedAt`) resolves with no
|
|
259
|
+
* `fieldConfig` and carries no field-level access control, so it is always
|
|
260
|
+
* readable and skipped too.
|
|
261
|
+
*/
|
|
262
|
+
async function checkKeyReadableOrThrow(
|
|
263
|
+
key: string,
|
|
264
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
265
|
+
listConfig: ListConfig<any>,
|
|
266
|
+
listName: string,
|
|
267
|
+
args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
|
|
268
|
+
kind: 'where' | 'orderBy',
|
|
269
|
+
): Promise<void> {
|
|
270
|
+
const resolved = resolveQueryField(key, listConfig.fields)
|
|
271
|
+
if (!resolved || resolved.fieldConfig === undefined) return
|
|
272
|
+
|
|
273
|
+
const readable = await isFieldReadableForPredicate(resolved.fieldConfig.access, args)
|
|
274
|
+
if (!readable) {
|
|
275
|
+
throw new ValidationError([
|
|
276
|
+
`Cannot query "${listName}" — "${key}" is denied by field-level read access. ` +
|
|
277
|
+
`A field the session cannot read cannot be named in a ${kind} (use sudo to bypass).`,
|
|
278
|
+
])
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Check field-level `read` access for every key at ONE level of a `where`
|
|
284
|
+
* clause, recursing only into logical operators (`AND`/`OR`/`NOT`) — never
|
|
285
|
+
* into a relationship field's own nested value. Exported for `access-filter.ts`'s
|
|
286
|
+
* #916 reuse (see module doc comment above) — called once per hop against the
|
|
287
|
+
* RELATED list's own config.
|
|
288
|
+
*/
|
|
289
|
+
export async function walkWhereReadAccess(
|
|
290
|
+
where: unknown,
|
|
291
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
292
|
+
listConfig: ListConfig<any>,
|
|
293
|
+
listName: string,
|
|
294
|
+
args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
if (where === null || typeof where !== 'object') return
|
|
297
|
+
|
|
298
|
+
if (Array.isArray(where)) {
|
|
299
|
+
for (const entry of where) await walkWhereReadAccess(entry, listConfig, listName, args)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
|
|
304
|
+
if (LOGICAL_OPERATORS.has(key)) {
|
|
305
|
+
await walkWhereReadAccess(value, listConfig, listName, args)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
// Deliberately does not recurse into a relationship field's own nested
|
|
309
|
+
// value: it checks whether THIS list's relationship field may be named
|
|
310
|
+
// (its own `read` access) — a field on the RELATED list nested inside it
|
|
311
|
+
// is checked by the CALLER re-invoking this function against the related
|
|
312
|
+
// list's config (see #916 in the module doc comment above).
|
|
313
|
+
await checkKeyReadableOrThrow(key, listConfig, listName, args, 'where')
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function walkOrderByReadAccess(
|
|
318
|
+
orderBy: unknown,
|
|
319
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
320
|
+
listConfig: ListConfig<any>,
|
|
321
|
+
listName: string,
|
|
322
|
+
args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
|
|
323
|
+
): Promise<void> {
|
|
324
|
+
if (orderBy === null || typeof orderBy !== 'object') return
|
|
325
|
+
|
|
326
|
+
if (Array.isArray(orderBy)) {
|
|
327
|
+
for (const entry of orderBy) await walkOrderByReadAccess(entry, listConfig, listName, args)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const key of Object.keys(orderBy as Record<string, unknown>)) {
|
|
332
|
+
await checkKeyReadableOrThrow(key, listConfig, listName, args, 'orderBy')
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Validate a caller-supplied `where`/`orderBy` against field-level `read`
|
|
338
|
+
* access, recursing into logical operators (`AND`/`OR`/`NOT`) the same way
|
|
339
|
+
* `validateQueryKeys` does. Throws a `ValidationError` naming the list and
|
|
340
|
+
* the offending key on the first read-denied field found. `isSudo` bypasses
|
|
341
|
+
* the check entirely, matching `validateQueryKeys` and the write path's
|
|
342
|
+
* `sudo` escape hatch.
|
|
343
|
+
*/
|
|
344
|
+
export async function validateQueryFieldReadAccess(args: {
|
|
345
|
+
where?: unknown
|
|
346
|
+
orderBy?: unknown
|
|
347
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
348
|
+
listConfig: ListConfig<any>
|
|
349
|
+
listName: string
|
|
350
|
+
session: Session | null
|
|
351
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
352
|
+
isSudo: boolean
|
|
353
|
+
}): Promise<void> {
|
|
354
|
+
const { where, orderBy, listConfig, listName, session, context, isSudo } = args
|
|
355
|
+
if (isSudo) return
|
|
356
|
+
const evalArgs = { session, context }
|
|
357
|
+
if (where !== undefined) await walkWhereReadAccess(where, listConfig, listName, evalArgs)
|
|
358
|
+
if (orderBy !== undefined) await walkOrderByReadAccess(orderBy, listConfig, listName, evalArgs)
|
|
359
|
+
}
|
|
@@ -99,7 +99,6 @@ export async function buildRelationshipCountSelect(
|
|
|
99
99
|
return Object.keys(select).length > 0 ? select : undefined
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
/** Read a to-many relationship's count off a fetched row's `_count` payload. */
|
|
103
102
|
function readRelationshipCount(row: Record<string, unknown>, fieldName: string): number {
|
|
104
103
|
const counts = row._count
|
|
105
104
|
if (counts && typeof counts === 'object') {
|
|
@@ -109,7 +108,6 @@ function readRelationshipCount(row: Record<string, unknown>, fieldName: string):
|
|
|
109
108
|
return 0
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
/** Whether a count satisfies a Filter operator/value comparison. */
|
|
113
111
|
function matchesCount(count: number, operator: FilterOperator, value: number): boolean {
|
|
114
112
|
switch (operator) {
|
|
115
113
|
case 'eq':
|
|
@@ -140,7 +138,6 @@ function asCountDelegate(value: unknown): CountFindManyDelegate | null {
|
|
|
140
138
|
return null
|
|
141
139
|
}
|
|
142
140
|
|
|
143
|
-
/** Extract the `RelationshipCountFilterMarker` from a condition value, if present. */
|
|
144
141
|
function readCountMarker(value: unknown): RelationshipCountFilterMarker | null {
|
|
145
142
|
if (!value || typeof value !== 'object') return null
|
|
146
143
|
const marker = (value as Record<string, unknown>)[RELATIONSHIP_COUNT_FILTER_KEY]
|
|
@@ -291,13 +288,8 @@ export async function resolveRelationshipCountFilters(
|
|
|
291
288
|
args,
|
|
292
289
|
config,
|
|
293
290
|
)
|
|
294
|
-
// Preserve any sibling conditions
|
|
295
|
-
//
|
|
296
|
-
// currently guarantees each AND-member (and the no-AND single object) carries
|
|
297
|
-
// exactly one field condition, so `siblings` is empty today and this equals the
|
|
298
|
-
// previous wholesale replacement — but if a future engine change ever merged
|
|
299
|
-
// multiple conditions into one member, spreading keeps the marker's siblings
|
|
300
|
-
// from being silently dropped.
|
|
291
|
+
// Preserve any sibling conditions rather than replacing the member
|
|
292
|
+
// wholesale — see `mergeResolvedMember` above for why.
|
|
301
293
|
const siblings: Record<string, unknown> = { ...member }
|
|
302
294
|
delete siblings[found.field]
|
|
303
295
|
resolvedMembers.push(mergeResolvedMember(siblings, resolved))
|