@simtlix/simfinity-core 3.12.0 → 3.14.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/README.md +15 -3
- package/package.json +2 -1
- package/src/const/QLQuantifier.js +21 -0
- package/src/index.js +2 -0
- package/src/metadata.js +24 -0
- package/src/query-emptiness.js +2 -2
- package/src/query-frames.js +510 -0
- package/src/query-limits.js +9 -2
- package/src/query-operators.js +23 -9
- package/src/query-plan.js +236 -155
- package/src/record-version.js +235 -0
- package/src/runtime.js +664 -168
- package/types/index.d.ts +181 -23
package/src/runtime.js
CHANGED
|
@@ -13,14 +13,16 @@ import SimfinityError from './errors/simfinity.error.js';
|
|
|
13
13
|
import InternalServerError from './errors/internal-server.error.js';
|
|
14
14
|
import { ForbiddenError } from './auth/errors.js';
|
|
15
15
|
import QLOperator from './const/QLOperator.js';
|
|
16
|
+
import QLQuantifier, { QUANTIFIED_LISTS } from './const/QLQuantifier.js';
|
|
17
|
+
import { aggregateJoinPrefixes, assertSortSyntax, frameUnits, validateQuantifiedGroups } from './query-frames.js';
|
|
16
18
|
import QLValue from './const/QLValue.js';
|
|
17
19
|
import QLSort from './const/QLSort.js';
|
|
18
20
|
import {
|
|
19
|
-
collectQueryOperators, collectQueryPathEntries,
|
|
21
|
+
collectQueryOperators, collectQueryPathEntries, walkQueryPath,
|
|
20
22
|
} from './query-plan.js';
|
|
21
23
|
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
22
24
|
import {
|
|
23
|
-
assertEmptinessPath, describeEmptiness,
|
|
25
|
+
assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
|
|
24
26
|
} from './query-emptiness.js';
|
|
25
27
|
import { relationFieldOwners } from './relation-owners.js';
|
|
26
28
|
import { isThenable } from './auth/thenable.js';
|
|
@@ -33,6 +35,12 @@ import {
|
|
|
33
35
|
import {
|
|
34
36
|
fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
|
|
35
37
|
} from './introspection.js';
|
|
38
|
+
import {
|
|
39
|
+
MAX_RECORD_VERSION, RecordVersion, RecordVersionRef, assertVersionInput, concurrencyDeclared, createVersionFieldConfig,
|
|
40
|
+
isProtectedType, isVersionField, readStoredVersion, recordVersionFieldMisuse, retainedStoredVersion, unadvancedVersionError,
|
|
41
|
+
unexpectedDeleteError, versionConflictError, versionConnectionCollision, versionEmbeddedMisuse, versionExhaustedError,
|
|
42
|
+
versionFieldCollision, versionManagedError,
|
|
43
|
+
} from './record-version.js';
|
|
36
44
|
|
|
37
45
|
// Resolvers Simfinity generates. Any other resolver on a registered type when its schema is first
|
|
38
46
|
// created is application code, such as a masking resolver, recorded once per type so later
|
|
@@ -85,6 +93,33 @@ const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER
|
|
|
85
93
|
// Generated inputs never write a readOnly field, nor a virtual one, which virtual implies.
|
|
86
94
|
const isReadOnlyField = (field) => !!field.extensions?.readOnly || isVirtualField(field);
|
|
87
95
|
|
|
96
|
+
// Optimistic concurrency (#98). A type is protected once it carries the version field createSchema()
|
|
97
|
+
// injects, which it keeps for good, so code that builds schemas and runs requests asks this instead of
|
|
98
|
+
// reading the live `extensions.concurrency`.
|
|
99
|
+
const isProtected = (gqltype) => isVersionField(gqltype.getFields().version);
|
|
100
|
+
// Whether each registered type was protected when the first createSchema() that contained it returned,
|
|
101
|
+
// for every runtime of this copy of the core code. A failed createSchema() records nothing.
|
|
102
|
+
const protectionStatus = new WeakMap();
|
|
103
|
+
|
|
104
|
+
const invalidWriteFeatures = (message) => {
|
|
105
|
+
throw new SimfinityError(`DatabaseAdapter writeFeatures ${message}`, 'INVALID_WRITE_FEATURES', 500);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Reads an adapter's `writeFeatures` declaration once, into a frozen copy. Undefined or null declares
|
|
109
|
+
// nothing; a plain object may declare `optimisticConcurrency` as a boolean, and its other members are
|
|
110
|
+
// left for later features. Anything else throws INVALID_WRITE_FEATURES (500).
|
|
111
|
+
const resolveWriteFeatures = (writeFeatures) => {
|
|
112
|
+
if (writeFeatures == null) return Object.freeze({ optimisticConcurrency: false });
|
|
113
|
+
if (typeof writeFeatures !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(writeFeatures))) {
|
|
114
|
+
invalidWriteFeatures('must be a plain object');
|
|
115
|
+
}
|
|
116
|
+
const { optimisticConcurrency } = writeFeatures;
|
|
117
|
+
if (optimisticConcurrency !== undefined && typeof optimisticConcurrency !== 'boolean') {
|
|
118
|
+
invalidWriteFeatures('optimisticConcurrency must be a boolean');
|
|
119
|
+
}
|
|
120
|
+
return Object.freeze({ optimisticConcurrency: optimisticConcurrency === true });
|
|
121
|
+
};
|
|
122
|
+
|
|
88
123
|
// Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
|
|
89
124
|
const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
|
|
90
125
|
const invalidScope = (typeName, detail) => new SimfinityError(
|
|
@@ -148,7 +183,7 @@ const assertScopeControlArg = (name, value) => {
|
|
|
148
183
|
};
|
|
149
184
|
|
|
150
185
|
// Moves a joined type's scope group below the relation path that entered it.
|
|
151
|
-
const prefixScopeGroup = (group, prefix) => {
|
|
186
|
+
const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
152
187
|
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
153
188
|
throw new SimfinityError('Expected a scope filter group object', 'INVALID_FILTER_VALUE', 400);
|
|
154
189
|
}
|
|
@@ -160,12 +195,15 @@ const prefixScopeGroup = (group, prefix) => {
|
|
|
160
195
|
value: condition?.value,
|
|
161
196
|
}));
|
|
162
197
|
}
|
|
163
|
-
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix));
|
|
164
|
-
if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix));
|
|
198
|
+
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
199
|
+
if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
200
|
+
for (const key of QUANTIFIED_LISTS) if (group[key] != null) {
|
|
201
|
+
prefixed[key] = group[key].map((item) => ({ ...copyQuantifiedGroup(item), path: `${prefix}.${item.path}` }));
|
|
202
|
+
}
|
|
165
203
|
return prefixed;
|
|
166
204
|
};
|
|
167
205
|
|
|
168
|
-
const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
206
|
+
const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
169
207
|
const conditions = [];
|
|
170
208
|
const groups = [];
|
|
171
209
|
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
@@ -174,10 +212,12 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
174
212
|
assertScopeControlArg(name, value);
|
|
175
213
|
continue;
|
|
176
214
|
}
|
|
177
|
-
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
|
|
215
|
+
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)));
|
|
178
216
|
else if (name === 'OR') {
|
|
179
217
|
const branches = [].concat(value);
|
|
180
|
-
if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix)) });
|
|
218
|
+
if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)) });
|
|
219
|
+
} else if (value.quantifier != null) {
|
|
220
|
+
groups.push({ [value.quantifier]: [{ path: `${prefix}.${name}`, conditions: (value.terms ?? []).map(({ path, ...term }) => ({ field: path, ...term })) }] });
|
|
181
221
|
} else if (Object.hasOwn(value, 'terms')) {
|
|
182
222
|
if (!Array.isArray(value.terms) || !value.terms.length) {
|
|
183
223
|
throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
|
|
@@ -280,6 +320,24 @@ const assertFieldMetadataNamesFree = (roots) => {
|
|
|
280
320
|
if (clash) throw reservedTypeNameError(clash);
|
|
281
321
|
};
|
|
282
322
|
|
|
323
|
+
// A schema with protected types contains RecordVersion, and RecordVersionRef whenever a generated input
|
|
324
|
+
// reaches a collection of protected items, so an application type with either name, other than the
|
|
325
|
+
// instances this module exports, is rejected there (#98). Like the field metadata names, they are first
|
|
326
|
+
// checked before anything is built, reading input objects by their own name only, and then in the whole
|
|
327
|
+
// schema when it is constructed.
|
|
328
|
+
const versionTypeClash = (roots, options) => [RecordVersion, RecordVersionRef]
|
|
329
|
+
.map((own) => findSchemaType(roots, own.name, own, options)).find(Boolean) || null;
|
|
330
|
+
// A scalar named RecordVersion with core's own description is almost always core's scalar from a
|
|
331
|
+
// second copy of this package, which protected types cannot work with.
|
|
332
|
+
const reservedVersionTypeNameError = (clash) => new SimfinityError(
|
|
333
|
+
clash instanceof GraphQLScalarType && clash.name === RecordVersion.name && clash.description === RecordVersion.description
|
|
334
|
+
? 'RecordVersion is reserved for optimistic concurrency, and this RecordVersion comes from another copy of '
|
|
335
|
+
+ '@simtlix/simfinity-core: two copies of the package are loaded; import RecordVersion from the facade the '
|
|
336
|
+
+ 'application uses'
|
|
337
|
+
: `${clash.name} is reserved for optimistic concurrency; rename the application type`,
|
|
338
|
+
'RESERVED_TYPE_NAME', 500,
|
|
339
|
+
);
|
|
340
|
+
|
|
283
341
|
const buildSchemaConfig = (query, mutation) => {
|
|
284
342
|
const config = { query };
|
|
285
343
|
if (Object.keys(mutation.getFields()).length > 0) {
|
|
@@ -292,12 +350,15 @@ const buildSchemaConfig = (query, mutation) => {
|
|
|
292
350
|
// those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
|
|
293
351
|
// when the schema also has those names, in every schema the process constructs from then on. A
|
|
294
352
|
// construction that fails puts the previous metadata types back, so only a schema that exists fixes
|
|
295
|
-
// the names.
|
|
296
|
-
|
|
353
|
+
// the names. With `reserveVersionTypeNames`, for a schema with protected types, an application type
|
|
354
|
+
// named RecordVersion or RecordVersionRef fails, even one that only an input field reaches.
|
|
355
|
+
const constructSchema = (query, mutation, reserveVersionTypeNames = false) => {
|
|
297
356
|
const config = buildSchemaConfig(query, mutation);
|
|
298
357
|
const roots = Object.values(config);
|
|
299
358
|
const clash = fieldMetadataClash(roots);
|
|
300
359
|
if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
|
|
360
|
+
const versionClash = reserveVersionTypeNames && versionTypeClash(roots);
|
|
361
|
+
if (versionClash) throw reservedVersionTypeNameError(versionClash);
|
|
301
362
|
let names = null;
|
|
302
363
|
if (clash) {
|
|
303
364
|
const taken = (name) => findSchemaType(roots, name) !== null;
|
|
@@ -431,6 +492,21 @@ const readOwnField = markGenerated((parent, args, context, info) => {
|
|
|
431
492
|
return typeof property === 'function' ? property.call(parent, args, context, info) : property;
|
|
432
493
|
});
|
|
433
494
|
|
|
495
|
+
// Puts the version field into a protected type's getFields() map, after the type's own fields, unless
|
|
496
|
+
// the type carries it already: from an earlier schema, another runtime of this core copy that shares
|
|
497
|
+
// the type, or the type it was copied from. The resolver reads only the parent, so it binds the type to
|
|
498
|
+
// no runtime, and a record without a stored version, such as a MongoDB document written before the
|
|
499
|
+
// type was protected, reads as version 1. An invalid stored version is reported under the type being
|
|
500
|
+
// resolved, which a renamed copy shares the resolver with, or under the type it was injected into.
|
|
501
|
+
const injectVersionField = (gqltype) => {
|
|
502
|
+
if (isProtected(gqltype)) return;
|
|
503
|
+
const typeName = gqltype.name;
|
|
504
|
+
gqltype.getFields().version = createVersionFieldConfig(markGenerated((parent, args, context, info) => readStoredVersion(
|
|
505
|
+
retainedStoredVersion(parent) ?? ownValue(parent, 'version'),
|
|
506
|
+
{ typeName: info?.parentType?.name ?? typeName, id: ownValue(parent, '_id') ?? ownValue(parent, 'id') },
|
|
507
|
+
)));
|
|
508
|
+
};
|
|
509
|
+
|
|
434
510
|
const cloneInput = (value, type) => {
|
|
435
511
|
if (value === null || value === undefined || !type) return value;
|
|
436
512
|
if (type instanceof GraphQLNonNull) return cloneInput(value, type.ofType);
|
|
@@ -476,9 +552,12 @@ export const createRuntime = (adapter) => {
|
|
|
476
552
|
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
477
553
|
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
478
554
|
const {
|
|
479
|
-
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
|
|
555
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
|
|
480
556
|
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
481
557
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
558
|
+
// The write features the adapter implements, read once: with optimisticConcurrency, schemas may
|
|
559
|
+
// contain protected types (#98).
|
|
560
|
+
const writeFeatures = resolveWriteFeatures(adapter.writeFeatures);
|
|
482
561
|
|
|
483
562
|
const stateValue = adapter.stateValue || ((state) => state.name);
|
|
484
563
|
const runtimeIdentity = {};
|
|
@@ -494,6 +573,9 @@ export const createRuntime = (adapter) => {
|
|
|
494
573
|
const selfCollectionNameHolders = new Map();
|
|
495
574
|
const qualifiedSelfCollections = new Map();
|
|
496
575
|
const registeredMutations = {};
|
|
576
|
+
// Models createModel() returned for this runtime, as opposed to models supplied to connect(). A type
|
|
577
|
+
// with one was built for storage without a version field, even by a createSchema() that failed later.
|
|
578
|
+
const generatedModels = new WeakSet();
|
|
497
579
|
const registeredQueries = {};
|
|
498
580
|
const registeredViews = {};
|
|
499
581
|
const middlewares = [];
|
|
@@ -556,9 +638,9 @@ export const createRuntime = (adapter) => {
|
|
|
556
638
|
}
|
|
557
639
|
};
|
|
558
640
|
|
|
559
|
-
// Object and interface types
|
|
560
|
-
//
|
|
561
|
-
const
|
|
641
|
+
// Object and interface types reachable from these roots, following the output references of
|
|
642
|
+
// GraphQLSchema: fields, interfaces and union members. Input objects cannot hold either.
|
|
643
|
+
const collectOutputTypes = (roots) => {
|
|
562
644
|
const visited = new Set();
|
|
563
645
|
const outputTypes = [];
|
|
564
646
|
const visit = (type) => {
|
|
@@ -574,14 +656,20 @@ export const createRuntime = (adapter) => {
|
|
|
574
656
|
named.getInterfaces().forEach(visit);
|
|
575
657
|
for (const field of Object.values(named.getFields())) visit(field.type);
|
|
576
658
|
};
|
|
659
|
+
roots.forEach(visit);
|
|
660
|
+
return outputTypes;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// Object and interface types a schema of this runtime can reach from its registered types and
|
|
664
|
+
// custom operation results, following the same references as GraphQLSchema.
|
|
665
|
+
const collectSchemaOutputTypes = () => {
|
|
577
666
|
const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
|
|
578
667
|
const customOperations = [
|
|
579
668
|
...Object.values(registeredMutations),
|
|
580
669
|
...Object.values(registeredQueries),
|
|
581
670
|
];
|
|
582
671
|
const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
|
|
583
|
-
[...registeredTypes, ...customOutputTypes]
|
|
584
|
-
return outputTypes;
|
|
672
|
+
return collectOutputTypes([...registeredTypes, ...customOutputTypes]);
|
|
585
673
|
};
|
|
586
674
|
|
|
587
675
|
const hasUnresolvedRelation = (gqltype) => Object.values(gqltype.getFields()).some(
|
|
@@ -616,6 +704,7 @@ const QLTypeFilterExpression = new GraphQLInputObjectType({
|
|
|
616
704
|
name: 'QLTypeFilterExpression',
|
|
617
705
|
fields: () => ({
|
|
618
706
|
terms: { type: new GraphQLList(QLTypeFilter) },
|
|
707
|
+
quantifier: { type: QLQuantifier },
|
|
619
708
|
}),
|
|
620
709
|
});
|
|
621
710
|
|
|
@@ -629,9 +718,25 @@ const QLFilterCondition = new GraphQLInputObjectType({
|
|
|
629
718
|
}),
|
|
630
719
|
});
|
|
631
720
|
|
|
721
|
+
const QLQuantifiedGroup = new GraphQLInputObjectType({
|
|
722
|
+
name: 'QLQuantifiedGroup',
|
|
723
|
+
fields: () => ({
|
|
724
|
+
path: { type: new GraphQLNonNull(GraphQLString) },
|
|
725
|
+
AND: { type: new GraphQLList(QLFilterGroup) },
|
|
726
|
+
OR: { type: new GraphQLList(QLFilterGroup) },
|
|
727
|
+
ANY: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
728
|
+
ALL: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
729
|
+
NONE: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
730
|
+
conditions: { type: new GraphQLList(QLFilterCondition) },
|
|
731
|
+
}),
|
|
732
|
+
});
|
|
733
|
+
|
|
632
734
|
const QLFilterGroup = new GraphQLInputObjectType({
|
|
633
735
|
name: 'QLFilterGroup',
|
|
634
736
|
fields: () => ({
|
|
737
|
+
ANY: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
738
|
+
ALL: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
739
|
+
NONE: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
635
740
|
AND: { type: new GraphQLList(QLFilterGroup) },
|
|
636
741
|
OR: { type: new GraphQLList(QLFilterGroup) },
|
|
637
742
|
conditions: { type: new GraphQLList(QLFilterCondition) },
|
|
@@ -814,8 +919,10 @@ const createOneToManyInputType = (name, inputNamePrefix, itemType, connection,
|
|
|
814
919
|
+ `\`${operation}\` operation in generated inputs, ${omittedOperation[operation]()}`);
|
|
815
920
|
}
|
|
816
921
|
}
|
|
817
|
-
// Null
|
|
818
|
-
|
|
922
|
+
// Null entries are skipped, so `deleted` accepts them whatever the collection's item nullability.
|
|
923
|
+
// A protected item is deleted at the version the client read, whether or not the parent is
|
|
924
|
+
// protected (#98).
|
|
925
|
+
fields.deleted = { type: new GraphQLList(isProtected(itemType) ? RecordVersionRef : GraphQLID) };
|
|
819
926
|
return fields;
|
|
820
927
|
},
|
|
821
928
|
});
|
|
@@ -1050,6 +1157,14 @@ const buildInputType = (gqltype, reachable) => {
|
|
|
1050
1157
|
description: fieldEntry.description,
|
|
1051
1158
|
};
|
|
1052
1159
|
}
|
|
1160
|
+
// The version field is readOnly, so only this adds it, last and required, to a protected type's
|
|
1161
|
+
// update input, which updates, state actions and nested `updated` entries take (#98). Create inputs,
|
|
1162
|
+
// and the `added` items built from them, never get it. An update input that would hold only the
|
|
1163
|
+
// version, with no writable id or other field, is left without fields as before (#161), so the type
|
|
1164
|
+
// has no update mutation, state actions or nested `updated` entries, and keeps its delete mutation.
|
|
1165
|
+
if (isProtected(gqltype) && Object.keys(fieldsArgForUpdate).length > 0) {
|
|
1166
|
+
fieldsArgForUpdate.version = { type: new GraphQLNonNull(RecordVersion), description: argTypes.version.description };
|
|
1167
|
+
}
|
|
1053
1168
|
|
|
1054
1169
|
const inputTypeForAdd = new GraphQLInputObjectType({
|
|
1055
1170
|
name: `${gqltype.name}Input`,
|
|
@@ -1222,8 +1337,9 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
|
|
|
1222
1337
|
const collectionFields = {};
|
|
1223
1338
|
|
|
1224
1339
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
1225
|
-
// A virtual field stores nothing, whatever middleware or saveObject() passes for it
|
|
1226
|
-
|
|
1340
|
+
// A virtual field stores nothing, whatever middleware or saveObject() passes for it, and Simfinity
|
|
1341
|
+
// writes the version field of a protected type itself (#98).
|
|
1342
|
+
if (isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
|
|
1227
1343
|
const fieldValue = ownValue(args, fieldEntryName);
|
|
1228
1344
|
if (fieldEntry.extensions && fieldEntry.extensions.validations
|
|
1229
1345
|
&& fieldEntry.extensions.validations[operation]) {
|
|
@@ -1339,10 +1455,58 @@ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, s
|
|
|
1339
1455
|
const readByIdIsInert = (Model) => typeof adapter.readByIdIsInert === 'function'
|
|
1340
1456
|
&& adapter.readByIdIsInert(Model) === true;
|
|
1341
1457
|
|
|
1458
|
+
// Optimistic concurrency (#98). Every update, state action, delete and nested entry of a protected
|
|
1459
|
+
// type first reads its record whole, plain and locked: the pre-read, which also serves the embedded
|
|
1460
|
+
// merge, the from-state check and onDelete. Its version is normalized before any hook sees it, so a
|
|
1461
|
+
// MongoDB document stored before the type was protected shows 1, as on PostgreSQL.
|
|
1462
|
+
const normalizeProtectedVersion = (record, gqltype, id) => {
|
|
1463
|
+
record.version = readStoredVersion(record.version,
|
|
1464
|
+
{ typeName: gqltype.name, id: ownValue(record, '_id') ?? ownValue(record, 'id') ?? id });
|
|
1465
|
+
return record.version;
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
const readProtectedRecord = async (Model, gqltype, id, session) => {
|
|
1469
|
+
const record = await adapter.getById(Model, id, session, { plain: true, lock: true });
|
|
1470
|
+
if (record) normalizeProtectedVersion(record, gqltype, id);
|
|
1471
|
+
return record;
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
// Whether a stale or exhausted version that the pre-read found is reported at once, before the
|
|
1475
|
+
// record's validators, hooks and action run (decision 9): to every caller of a type without a
|
|
1476
|
+
// get_by_id scope, and to a caller who can read the record through that scope. The record is read as
|
|
1477
|
+
// a by-ID read does, in the operation's session and by its stored key, not the client's spelling of
|
|
1478
|
+
// the ID. A scope function that throws counts as hiding the record; errors of the gates and of
|
|
1479
|
+
// adapter.find propagate.
|
|
1480
|
+
const reportEarlyConflict = async (type, record, context, session) => {
|
|
1481
|
+
if (!getScope(type.gqltype, 'get_by_id')) return true;
|
|
1482
|
+
const key = String(record._id ?? record.id);
|
|
1483
|
+
const args = { id: { operator: 'EQ', value: key } };
|
|
1484
|
+
try {
|
|
1485
|
+
return await findVisibleById(type, args, session, context, key) !== null;
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
if (failedByIdScopes.has(args)) return false;
|
|
1488
|
+
throw error;
|
|
1489
|
+
}
|
|
1490
|
+
};
|
|
1491
|
+
|
|
1492
|
+
// Outcome 3 of the pre-read: the stored version is not the expected one, or an update or a state
|
|
1493
|
+
// action (`advances`) would take it past MAX_RECORD_VERSION; a delete does not advance it. When the
|
|
1494
|
+
// conflict is not reported early, the operation continues exactly as with a matching version, and the
|
|
1495
|
+
// conditional write reports it.
|
|
1496
|
+
const assertRecordVersion = async (gqltype, id, record, expectedVersion, advances, context, session) => {
|
|
1497
|
+
let error = null;
|
|
1498
|
+
if (record.version !== expectedVersion) error = versionConflictError(gqltype.name, id);
|
|
1499
|
+
else if (advances && expectedVersion === MAX_RECORD_VERSION) error = versionExhaustedError(gqltype.name, id);
|
|
1500
|
+
if (error && await reportEarlyConflict(typesDict.types[gqltype.name], record, context, session)) throw error;
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1342
1503
|
// The record is read for the onDelete hook; adapter.delete casts the ID, deletes and returns the
|
|
1343
1504
|
// record itself. Without a hook, the read is skipped when it is inert. A nested delete passes the
|
|
1344
|
-
// child it has already read as `current
|
|
1345
|
-
|
|
1505
|
+
// child it has already read as `current`, and a protected delete its pre-read record, null when it is
|
|
1506
|
+
// missing, with `precondition`: the type and the version the record must still have. The adapter then
|
|
1507
|
+
// deletes it only at that version, and a record it deleted at another one, which an adapter wrapper
|
|
1508
|
+
// that drops the options would delete, fails with DATABASE_ERROR and rolls back with the transaction.
|
|
1509
|
+
const onDelete = async (Model, controller, id, session, context, current, precondition = null) => {
|
|
1346
1510
|
const hasHook = !!(controller && controller.onDelete);
|
|
1347
1511
|
let currentObject = current;
|
|
1348
1512
|
if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
|
|
@@ -1353,7 +1517,13 @@ const onDelete = async (Model, controller, id, session, context, current) => {
|
|
|
1353
1517
|
await controller.onDelete(currentObject, session, context);
|
|
1354
1518
|
}
|
|
1355
1519
|
|
|
1356
|
-
return adapter.delete(Model, id, session);
|
|
1520
|
+
if (!precondition) return adapter.delete(Model, id, session);
|
|
1521
|
+
const { gqltype, expectedVersion } = precondition;
|
|
1522
|
+
const deleted = await adapter.delete(Model, id, session, { expectedVersion });
|
|
1523
|
+
if (deleted && readStoredVersion(deleted.version, { typeName: gqltype.name, id }) !== expectedVersion) {
|
|
1524
|
+
throw unexpectedDeleteError(gqltype.name, id);
|
|
1525
|
+
}
|
|
1526
|
+
return deleted;
|
|
1357
1527
|
};
|
|
1358
1528
|
|
|
1359
1529
|
const getEmbeddedFieldNames = (gqltype) => {
|
|
@@ -1428,7 +1598,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
|
|
|
1428
1598
|
// onUpdating, so that a malformed ID fails before the hook, and before nested collection items, so
|
|
1429
1599
|
// that the SQL adapter locks the parent row before child writes; otherwise it skips the read only
|
|
1430
1600
|
// when it is inert. A state action or a nested update has already read and checked the record
|
|
1431
|
-
// (`recordChecked`).
|
|
1601
|
+
// (`recordChecked`). A protected type never reads here: its pre-read record serves the merge (#98).
|
|
1432
1602
|
const readsStoredEmbedded = (Model, controller, materializedModel, embeddedFieldNames, recordChecked) => {
|
|
1433
1603
|
const usesStored = embeddedFieldNames.some((name) => {
|
|
1434
1604
|
const value = ownValue(materializedModel.modelArgs, name);
|
|
@@ -1442,17 +1612,29 @@ const readsStoredEmbedded = (Model, controller, materializedModel, embeddedField
|
|
|
1442
1612
|
|| !readByIdIsInert(Model);
|
|
1443
1613
|
};
|
|
1444
1614
|
|
|
1615
|
+
// Whether an update sets the version field, at the top level or inside an operator such as $set or
|
|
1616
|
+
// $unset. The adapter writes the version of a protected type (#98).
|
|
1617
|
+
const setsVersion = (update) => [update, ...Object.entries(update)
|
|
1618
|
+
.filter(([key]) => key.startsWith('$')).map(([, value]) => value)]
|
|
1619
|
+
.some((target) => target !== null && typeof target === 'object' && Object.hasOwn(target, 'version'));
|
|
1620
|
+
|
|
1621
|
+
// The update of a protected type (#98) takes from its caller the version captured before the
|
|
1622
|
+
// pre-read, `expectedVersion`, the immutable observed version, and the pre-read record (null when
|
|
1623
|
+
// missing). It merges embedded values from that record and writes only at the expected version,
|
|
1624
|
+
// which the adapter advances; onUpdating
|
|
1625
|
+
// cannot set the version, and a result that did not advance it fails with DATABASE_ERROR.
|
|
1445
1626
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
|
|
1446
|
-
recordChecked = false) => {
|
|
1627
|
+
recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
|
|
1628
|
+
const versioned = isProtected(gqltype);
|
|
1447
1629
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
1448
1630
|
const objectId = args.id;
|
|
1449
1631
|
const argTypes = gqltype.getFields();
|
|
1450
1632
|
const embeddedFieldNames = getEmbeddedFieldNames(gqltype);
|
|
1451
1633
|
|
|
1452
1634
|
if (embeddedFieldNames.length > 0
|
|
1453
|
-
&& readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked)) {
|
|
1635
|
+
&& (versioned || readsStoredEmbedded(Model, controller, materializedModel, embeddedFieldNames, recordChecked))) {
|
|
1454
1636
|
const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
|
|
1455
|
-
const currentObject = await adapter.getById(Model, objectId, session, {
|
|
1637
|
+
const currentObject = versioned ? record : await adapter.getById(Model, objectId, session, {
|
|
1456
1638
|
projection,
|
|
1457
1639
|
plain: true,
|
|
1458
1640
|
lock: true,
|
|
@@ -1477,7 +1659,8 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1477
1659
|
}
|
|
1478
1660
|
|
|
1479
1661
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
1480
|
-
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)
|
|
1662
|
+
if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)
|
|
1663
|
+
&& !isVersionField(fieldEntry)) {
|
|
1481
1664
|
materializedModel.modelArgs.$unset = {
|
|
1482
1665
|
...materializedModel.modelArgs.$unset,
|
|
1483
1666
|
[getFieldStorageName(fieldEntryName, fieldEntry)]: '',
|
|
@@ -1492,11 +1675,24 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1492
1675
|
|
|
1493
1676
|
if (controller && controller.onUpdating) {
|
|
1494
1677
|
await controller.onUpdating(objectId, update, session, context);
|
|
1678
|
+
if (versioned && setsVersion(update)) throw versionManagedError(gqltype.name, 'onUpdating');
|
|
1495
1679
|
}
|
|
1496
1680
|
|
|
1497
1681
|
if (linkToParent) linkToParent(update);
|
|
1498
|
-
const result =
|
|
1682
|
+
const result = versioned
|
|
1683
|
+
? await adapter.update(Model, objectId, update, session, { expectedVersion })
|
|
1684
|
+
: await adapter.update(Model, objectId, update, session);
|
|
1499
1685
|
if (!result) throw new SimfinityError(`${gqltype.name} ${objectId} is not valid`, 'NOT_VALID_ID', 404);
|
|
1686
|
+
// Diagnose a result that did not advance beyond the pre-read, including a dropped precondition
|
|
1687
|
+
// when the caller sent one revision less. This cannot prove arbitrary wrappers honored the atomic
|
|
1688
|
+
// condition: they must still forward it. The observation is captured before application code runs.
|
|
1689
|
+
if (versioned) {
|
|
1690
|
+
const returnedVersion = readStoredVersion(result.version, { typeName: gqltype.name, id: objectId });
|
|
1691
|
+
if (returnedVersion !== expectedVersion + 1
|
|
1692
|
+
|| (observedVersion !== undefined && returnedVersion <= observedVersion)) {
|
|
1693
|
+
throw unadvancedVersionError(gqltype.name, objectId);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1500
1696
|
|
|
1501
1697
|
if (materializedModel.collectionFields) {
|
|
1502
1698
|
// Children link to, and are owned through, the parent's stored key, as when a new parent is
|
|
@@ -1513,18 +1709,27 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1513
1709
|
return result;
|
|
1514
1710
|
};
|
|
1515
1711
|
|
|
1712
|
+
// A protected type's version is captured before the action can change `args`, and checked on the
|
|
1713
|
+
// plain pre-read record before the from-state check, which reads `state` from that record (#98).
|
|
1516
1714
|
const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
|
|
1517
|
-
const
|
|
1715
|
+
const versioned = isProtected(gqltype);
|
|
1716
|
+
const expectedVersion = versioned ? assertVersionInput(args.version) : undefined;
|
|
1717
|
+
const storedModel = versioned
|
|
1718
|
+
? await readProtectedRecord(Model, gqltype, args.id, session)
|
|
1719
|
+
: await adapter.getById(Model, args.id, session, { lock: true });
|
|
1720
|
+
const observedVersion = versioned ? storedModel?.version : undefined;
|
|
1518
1721
|
if (!storedModel) {
|
|
1519
1722
|
throw new SimfinityError(`${gqltype.name} ${args.id} is not valid`, 'NOT_VALID_ID', 404);
|
|
1520
1723
|
}
|
|
1724
|
+
if (versioned) await assertRecordVersion(gqltype, args.id, storedModel, expectedVersion, true, context, session);
|
|
1521
1725
|
if (storedModel.state === stateValue(actionField.from)) {
|
|
1522
1726
|
if (actionField.action) {
|
|
1523
1727
|
await actionField.action(args, session);
|
|
1524
1728
|
}
|
|
1525
1729
|
|
|
1526
1730
|
args.state = stateValue(actionField.to);
|
|
1527
|
-
let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true
|
|
1731
|
+
let result = await onUpdateSubject(Model, gqltype, controller, args, session, null, context, true,
|
|
1732
|
+
versioned ? { expectedVersion, observedVersion, record: storedModel } : undefined);
|
|
1528
1733
|
result = adapter.toObject(result);
|
|
1529
1734
|
result.state = actionField.to.value;
|
|
1530
1735
|
return result;
|
|
@@ -1539,11 +1744,15 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
|
|
|
1539
1744
|
typesDict.types[gqltype.name].stateMachine.initialState,
|
|
1540
1745
|
);
|
|
1541
1746
|
}
|
|
1747
|
+
// A protected record starts at version 1, whatever saveObject() or middleware supplied (#98).
|
|
1748
|
+
const versioned = isProtected(gqltype);
|
|
1749
|
+
if (versioned) materializedModel.modelArgs.version = 1;
|
|
1542
1750
|
|
|
1543
1751
|
const newObject = adapter.newRecord(Model, materializedModel.modelArgs, session);
|
|
1544
1752
|
|
|
1545
1753
|
if (controller && controller.onSaving) {
|
|
1546
1754
|
await controller.onSaving(newObject, args, session, context);
|
|
1755
|
+
if (versioned && newObject.version !== 1) throw versionManagedError(gqltype.name, 'onSaving');
|
|
1547
1756
|
}
|
|
1548
1757
|
|
|
1549
1758
|
if (linkToParent) linkToParent(newObject);
|
|
@@ -1635,12 +1844,30 @@ const assertCollectionWrites = (gqltype, input) => {
|
|
|
1635
1844
|
}
|
|
1636
1845
|
};
|
|
1637
1846
|
|
|
1847
|
+
// The capture and the pre-read of a protected top-level update or delete (#98): the version the
|
|
1848
|
+
// client sent is checked before anything is read, then against the pre-read record. A missing record
|
|
1849
|
+
// is not reported here; the update fails at its write with NOT_VALID_ID and the delete returns null,
|
|
1850
|
+
// as for an unprotected type.
|
|
1851
|
+
const preReadTopLevel = async (Model, gqltype, { id, version }, advances, context, session) => {
|
|
1852
|
+
const expectedVersion = assertVersionInput(version);
|
|
1853
|
+
const record = await readProtectedRecord(Model, gqltype, id, session);
|
|
1854
|
+
const observedVersion = record?.version;
|
|
1855
|
+
if (record) await assertRecordVersion(gqltype, id, record, expectedVersion, advances, context, session);
|
|
1856
|
+
return { expectedVersion, observedVersion, record };
|
|
1857
|
+
};
|
|
1858
|
+
|
|
1859
|
+
// A delete passes the ID, or { id, version } for a protected type, which each attempt copies; every
|
|
1860
|
+
// other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
|
|
1861
|
+
// the request sent.
|
|
1638
1862
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
1639
1863
|
session, context, inputType) => {
|
|
1640
1864
|
const limit = getMaxNestedOperations();
|
|
1641
1865
|
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
1642
1866
|
if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
|
|
1643
|
-
const
|
|
1867
|
+
const versioned = isProtected(gqltype);
|
|
1868
|
+
let nextInput = () => args;
|
|
1869
|
+
if (operation !== operations.DELETE) nextInput = attemptInputs(args, inputType);
|
|
1870
|
+
else if (versioned) nextInput = () => ({ ...args });
|
|
1644
1871
|
return adapter.withTransaction(
|
|
1645
1872
|
session,
|
|
1646
1873
|
async (mySession) => {
|
|
@@ -1648,10 +1875,17 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
|
|
|
1648
1875
|
switch (operation) {
|
|
1649
1876
|
case operations.SAVE:
|
|
1650
1877
|
return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
|
|
1651
|
-
case operations.UPDATE:
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
return
|
|
1878
|
+
case operations.UPDATE: {
|
|
1879
|
+
const precondition = versioned
|
|
1880
|
+
? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
|
|
1881
|
+
return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
|
|
1882
|
+
}
|
|
1883
|
+
case operations.DELETE: {
|
|
1884
|
+
if (!versioned) return onDelete(Model, controller, attemptArgs, mySession, context);
|
|
1885
|
+
const { expectedVersion, record } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
|
|
1886
|
+
mySession);
|
|
1887
|
+
return onDelete(Model, controller, attemptArgs.id, mySession, context, record, { gqltype, expectedVersion });
|
|
1888
|
+
}
|
|
1655
1889
|
case operations.STATE_CHANGED:
|
|
1656
1890
|
return onStateChanged(Model, gqltype, controller,
|
|
1657
1891
|
attemptArgs, mySession, actionField, context);
|
|
@@ -1684,14 +1918,28 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1684
1918
|
if (item.$set) delete item.$set[connection.storageFieldName];
|
|
1685
1919
|
};
|
|
1686
1920
|
|
|
1921
|
+
// A protected child is updated or deleted at the version its entry carries (#98). A deleted entry is
|
|
1922
|
+
// { id, version } for item middleware; one that is not an object, such as a string ID, has no version.
|
|
1923
|
+
const versioned = isProtected(collectionGQLType) && operationType !== operations.SAVE;
|
|
1687
1924
|
for (const element of collectionFieldsList) {
|
|
1688
1925
|
if (element === null) continue;
|
|
1689
|
-
|
|
1926
|
+
let args = { input: element };
|
|
1927
|
+
if (operationType === operations.DELETE) {
|
|
1928
|
+
args = versioned ? { id: element?.id, version: element?.version } : { id: element };
|
|
1929
|
+
}
|
|
1690
1930
|
await executeMiddleware({ type, args, operation: operationType, context });
|
|
1691
1931
|
|
|
1932
|
+
// Captured after the item's middleware, before the child is read.
|
|
1933
|
+
let expectedVersion;
|
|
1934
|
+
if (versioned) {
|
|
1935
|
+
expectedVersion = assertVersionInput(operationType === operations.DELETE ? args.version : args.input.version);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1692
1938
|
// The ownership check reads the child (the SQL adapter locks its row). onDelete receives this
|
|
1693
|
-
// record, and an update reads the child again only to merge embedded values
|
|
1939
|
+
// record, and an update reads the child again only to merge embedded values, unless the child is
|
|
1940
|
+
// protected: then this is its pre-read, whose version is checked after ownership.
|
|
1694
1941
|
let child;
|
|
1942
|
+
let observedVersion;
|
|
1695
1943
|
if (operationType === operations.UPDATE || operationType === operations.DELETE) {
|
|
1696
1944
|
const id = operationType === operations.DELETE ? args.id : args.input.id;
|
|
1697
1945
|
child = await adapter.getById(type.model, id, session, { plain: true, lock: true });
|
|
@@ -1701,6 +1949,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1701
1949
|
if (String(child[connection.storageFieldName]) !== String(parentKey)) {
|
|
1702
1950
|
throw new ForbiddenError('Child does not belong to this parent');
|
|
1703
1951
|
}
|
|
1952
|
+
if (versioned) {
|
|
1953
|
+
observedVersion = normalizeProtectedVersion(child, collectionGQLType, id);
|
|
1954
|
+
await assertRecordVersion(collectionGQLType, id, child, expectedVersion, operationType === operations.UPDATE,
|
|
1955
|
+
context, session);
|
|
1956
|
+
}
|
|
1704
1957
|
}
|
|
1705
1958
|
|
|
1706
1959
|
switch (operationType) {
|
|
@@ -1710,10 +1963,11 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1710
1963
|
break;
|
|
1711
1964
|
case operations.UPDATE:
|
|
1712
1965
|
await onUpdateSubject(type.model, collectionGQLType, type.controller,
|
|
1713
|
-
args.input, session, linkToParent, context, true);
|
|
1966
|
+
args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
|
|
1714
1967
|
break;
|
|
1715
1968
|
case operations.DELETE:
|
|
1716
|
-
await onDelete(type.model, type.controller, args.id, session, context, child
|
|
1969
|
+
await onDelete(type.model, type.controller, args.id, session, context, child,
|
|
1970
|
+
versioned ? { gqltype: collectionGQLType, expectedVersion } : null);
|
|
1717
1971
|
}
|
|
1718
1972
|
}
|
|
1719
1973
|
};
|
|
@@ -1780,8 +2034,10 @@ const copyFilterGroup = (group) => {
|
|
|
1780
2034
|
if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
|
|
1781
2035
|
if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
|
|
1782
2036
|
if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
|
|
2037
|
+
for (const key of QUANTIFIED_LISTS) if (Array.isArray(group[key])) copy[key] = group[key].map(copyQuantifiedGroup);
|
|
1783
2038
|
return copy;
|
|
1784
2039
|
};
|
|
2040
|
+
const copyQuantifiedGroup = (group) => copyFilterGroup(group);
|
|
1785
2041
|
const copyFieldFilter = (filter) => {
|
|
1786
2042
|
const copy = copyFilterCondition(filter);
|
|
1787
2043
|
if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
|
|
@@ -1818,12 +2074,19 @@ const copyClientFilters = (args, operation) => {
|
|
|
1818
2074
|
// non-queryable field is not rejected; a virtual one still is.
|
|
1819
2075
|
const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
|
|
1820
2076
|
copyClientFilters(args, operation);
|
|
2077
|
+
assertSortSyntax(args.sort);
|
|
1821
2078
|
const joinedScopes = new Map();
|
|
2079
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
1822
2080
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
1823
2081
|
for (const {
|
|
1824
|
-
segments, group, member, operator,
|
|
2082
|
+
segments, group, member, operator, use, frames, quantifier,
|
|
1825
2083
|
} of entries) {
|
|
1826
|
-
const
|
|
2084
|
+
const filter = use === 'filter';
|
|
2085
|
+
const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
|
|
2086
|
+
const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
|
|
2087
|
+
const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
|
|
2088
|
+
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2089
|
+
&& !(filter && (frames.length || quantifier || (cut && !anchored)));
|
|
1827
2090
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
1828
2091
|
const path = segments.slice(0, index + 1).join('.');
|
|
1829
2092
|
if (isVirtualField(field)) {
|
|
@@ -1833,8 +2096,11 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
|
|
|
1833
2096
|
if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
|
|
1834
2097
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
1835
2098
|
}
|
|
1836
|
-
if (!joins) return;
|
|
1837
2099
|
const relation = field.extensions?.relation;
|
|
2100
|
+
if (use === 'sort' && operation !== 'aggregate' && relation && !relation.embedded && getListShape(field.type)) {
|
|
2101
|
+
throw new SimfinityError(`Sort path ${segments.join('.')} crosses the collection ${type.name}.${fieldName}; a record has no single value there`, 'INVALID_FILTER_PATH', 400);
|
|
2102
|
+
}
|
|
2103
|
+
if (!joins || (cut && index >= cut.index)) return;
|
|
1838
2104
|
if (!relation || relation.embedded) return;
|
|
1839
2105
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
1840
2106
|
if (!getScope(target?.gqltype, 'find')) return;
|
|
@@ -1916,31 +2182,43 @@ const assertEmptinessForm = (gqltype, {
|
|
|
1916
2182
|
return true;
|
|
1917
2183
|
};
|
|
1918
2184
|
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
//
|
|
1922
|
-
//
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2185
|
+
// Every runtime gate validates the received language, before rewriting or filling any markers.
|
|
2186
|
+
// Legacy adapters keep the detailed 2b diagnostics; quantified adapters use the shared validator's
|
|
2187
|
+
// frame-relative checks first. The separate operator inspection includes ignored beside-terms
|
|
2188
|
+
// carriers, which do not become predicates but must not use known undeclared capabilities.
|
|
2189
|
+
const queryFrameContext = (gqltype, args, operation) => ({
|
|
2190
|
+
gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
|
|
2191
|
+
operators: supportedOperators,
|
|
2192
|
+
J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
|
|
2193
|
+
scopeMarkers: 'pending',
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
const entryType = (gqltype, entry) => {
|
|
2197
|
+
let type = gqltype;
|
|
2198
|
+
const prefix = entry.segments.slice(0, entry.segments.length - entry.relative.length);
|
|
2199
|
+
if (prefix.length) walkQueryPath(gqltype, prefix, ({ field }) => { type = getNamedType(field.type); });
|
|
2200
|
+
return type;
|
|
2201
|
+
};
|
|
2202
|
+
|
|
1929
2203
|
const assertQueryConditions = (gqltype, args, operation) => {
|
|
1930
|
-
|
|
2204
|
+
if (supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
|
|
2205
|
+
const conditions = [];
|
|
1931
2206
|
for (const entry of collectQueryOperators(args, operation)) {
|
|
1932
2207
|
const { operator, path } = entry;
|
|
2208
|
+
const type = entryType(gqltype, entry);
|
|
1933
2209
|
let shape = null;
|
|
1934
2210
|
if (isEmptinessOperator(operator)) {
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2211
|
+
// The validator owns quantified term forms; old top-frame forms retain their diagnostics.
|
|
2212
|
+
if (entry.frames.length || assertEmptinessForm(gqltype, entry)) {
|
|
2213
|
+
if (entry.relative.length) shape = describeEmptiness(type, entry.relative, {
|
|
2214
|
+
operator, value: entry.value, path: entry.relative.join('.'), referenceEmptiness: supportsReferenceEmptiness,
|
|
1938
2215
|
});
|
|
1939
2216
|
}
|
|
1940
2217
|
} else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
|
|
1941
|
-
|
|
2218
|
+
conditions.push({ entry, shape, type });
|
|
1942
2219
|
}
|
|
1943
|
-
|
|
2220
|
+
if (!supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
|
|
2221
|
+
return { conditions, gqltype, args, operation };
|
|
1944
2222
|
};
|
|
1945
2223
|
|
|
1946
2224
|
// Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
|
|
@@ -1957,9 +2235,8 @@ const assertQueryConditions = (gqltype, args, operation) => {
|
|
|
1957
2235
|
// the prefixed conditions are applied as trusted ones, which read stored data.
|
|
1958
2236
|
const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
|
|
1959
2237
|
const { gqltype } = target;
|
|
1960
|
-
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
|
|
2238
|
+
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator, frames }) => !frames.length && isEmptinessOperator(operator));
|
|
1961
2239
|
if (!entries.length) return;
|
|
1962
|
-
assertStoredQueryPaths(gqltype, scopeArgs, 'find');
|
|
1963
2240
|
let throughList = false;
|
|
1964
2241
|
walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
|
|
1965
2242
|
// Rule 6, here or on the target's own endpoint, is the rejection thrown below.
|
|
@@ -1987,20 +2264,28 @@ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target
|
|
|
1987
2264
|
+ `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
|
|
1988
2265
|
};
|
|
1989
2266
|
|
|
1990
|
-
//
|
|
1991
|
-
//
|
|
1992
|
-
const assertJoinedScopePaths = (groups, prefix, target) => {
|
|
2267
|
+
// Declared collection frames preserve cardinality. A legacy adapter, or a prefix through an
|
|
2268
|
+
// embedded list, still cannot lift collection scopes; vector reference scopes remain forbidden.
|
|
2269
|
+
const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
|
|
2270
|
+
let embeddedPrefix = false;
|
|
2271
|
+
walkQueryPath(rootType, prefix.split('.'), ({ field }) => {
|
|
2272
|
+
embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
|
|
2273
|
+
});
|
|
1993
2274
|
const depth = prefix.split('.').length;
|
|
1994
|
-
for (const segments of
|
|
2275
|
+
for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
|
|
1995
2276
|
let inList = false;
|
|
1996
|
-
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field }) => {
|
|
2277
|
+
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
|
|
1997
2278
|
const relation = field.extensions?.relation;
|
|
1998
2279
|
const list = !!getListShape(field.type);
|
|
1999
|
-
if (relation && !relation.embedded && (list ||
|
|
2280
|
+
if (relation && !relation.embedded && (inList || (list && (!supportsQuantifiers || embeddedPrefix)))) {
|
|
2000
2281
|
throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope filters through `
|
|
2001
2282
|
+ `${list ? 'the collection' : 'the relation'} ${type.name}.${fieldName}${list ? '' : ' inside a list'}`);
|
|
2002
2283
|
}
|
|
2003
|
-
|
|
2284
|
+
if (relation && !relation.embedded && quantifier && embeddedPrefix && index === segments.length - depth - 1) {
|
|
2285
|
+
throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope tests `
|
|
2286
|
+
+ `the quantified reference ${type.name}.${fieldName} under an embedded-list prefix`);
|
|
2287
|
+
}
|
|
2288
|
+
inList = relation && !relation.embedded ? false : inList || list;
|
|
2004
2289
|
});
|
|
2005
2290
|
}
|
|
2006
2291
|
};
|
|
@@ -2014,7 +2299,7 @@ const appendAndGroups = (owner, groups) => {
|
|
|
2014
2299
|
|
|
2015
2300
|
// The deepest AND/OR group nesting the backends accept; groups listed in args.AND/OR are at depth 0.
|
|
2016
2301
|
const MAX_FILTER_GROUP_DEPTH = 5;
|
|
2017
|
-
const nestedGroups = (group) => [...
|
|
2302
|
+
const nestedGroups = (group) => ['AND', 'OR', ...QUANTIFIED_LISTS].flatMap((key) => [].concat(group[key] ?? []));
|
|
2018
2303
|
const groupDepth = (group) => Math.max(0, ...nestedGroups(group)
|
|
2019
2304
|
.filter((item) => item && typeof item === 'object').map((item) => groupDepth(item) + 1));
|
|
2020
2305
|
|
|
@@ -2041,10 +2326,13 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2041
2326
|
}] of joinedScopes) {
|
|
2042
2327
|
const scopeArgs = {};
|
|
2043
2328
|
await executeScope({ type: target, args: scopeArgs, operation: 'find', context: params.context });
|
|
2044
|
-
const
|
|
2329
|
+
const kept = scopeFilterArgs(scopeArgs);
|
|
2330
|
+
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2331
|
+
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2332
|
+
const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
|
|
2045
2333
|
if (!groups.length) continue;
|
|
2046
2334
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
2047
|
-
assertJoinedScopePaths(groups, prefix, target);
|
|
2335
|
+
assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
|
|
2048
2336
|
assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
|
|
2049
2337
|
queryGroups ??= collectFilterGroups(params.args);
|
|
2050
2338
|
const addedDepth = 1 + Math.max(...groups.map(groupDepth));
|
|
@@ -2054,60 +2342,93 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2054
2342
|
}
|
|
2055
2343
|
};
|
|
2056
2344
|
|
|
2057
|
-
// Client scope
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
// issued is still there and that no other one is, gates the final arguments, then fills each marker
|
|
2062
|
-
// with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
|
|
2063
|
-
// { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
|
|
2064
|
-
// those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
|
|
2065
|
-
// Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
|
|
2066
|
-
// anywhere else, inside a value for one, is never read.
|
|
2345
|
+
// Client scope provenance is carried by terms, conditions and explicit frames, never by plain
|
|
2346
|
+
// logical groups or values. Only reads of registered scoped targets are marked. Middleware sees
|
|
2347
|
+
// ordinal IDs; after detach and final validation, fill adds relative target-scope arguments. The
|
|
2348
|
+
// planner/compiler rewrite, not the runtime, splits those keys across implicit collection frames.
|
|
2067
2349
|
const SCOPE_MARKER = '__scope';
|
|
2068
2350
|
const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
|
|
2069
2351
|
+ 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
|
|
2070
2352
|
const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
|
|
2071
2353
|
const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
|
|
2072
2354
|
|
|
2073
|
-
//
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2355
|
+
// Relation metadata is position-relative when used for fill and absolute for eligibility/recording.
|
|
2356
|
+
const pathRelations = (gqltype, segments) => {
|
|
2357
|
+
const found = [];
|
|
2358
|
+
let insideList = false;
|
|
2359
|
+
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2360
|
+
const relation = field.extensions?.relation;
|
|
2361
|
+
const list = !!getListShape(field.type);
|
|
2362
|
+
if (relation && !relation.embedded) found.push({
|
|
2363
|
+
path: segments.slice(0, index + 1).join('.'), index,
|
|
2364
|
+
typeName: getNamedType(field.type).name, collection: list, insideList, owner: type.name, fieldName,
|
|
2365
|
+
});
|
|
2366
|
+
// A collection enters an element frame; only embedded lists make vector reference joins.
|
|
2367
|
+
if (relation && !relation.embedded && list) insideList = false;
|
|
2368
|
+
else if (!relation || relation.embedded) insideList ||= list;
|
|
2369
|
+
});
|
|
2370
|
+
return found;
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
// The walker retains scalar-filter terms for capability/auth inspection, but no compiler consumes
|
|
2374
|
+
// them. Such positions cannot establish client provenance, including when aliased with a predicate.
|
|
2375
|
+
const ignoredScalarTerm = (gqltype, entry) => {
|
|
2376
|
+
if (entry.form !== 'term' || entry.frames.length || typeof entry.field !== 'string') return false;
|
|
2377
|
+
const segments = entry.field.split('.');
|
|
2378
|
+
let scalar = false;
|
|
2379
|
+
walkQueryPath(gqltype, segments, ({ field, index }) => {
|
|
2380
|
+
if (index === segments.length - 1) scalar = !isObjectType(getNamedType(field.type));
|
|
2381
|
+
});
|
|
2382
|
+
return scalar;
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
const ordinaryFrameCarrier = (gqltype, entry, J) => supportsQuantifiers && entry.construct
|
|
2386
|
+
&& !ignoredScalarTerm(gqltype, entry)
|
|
2387
|
+
&& entry.form !== 'beside-terms' && (entry.frames.length || pathRelations(gqltype, entry.segments)
|
|
2388
|
+
.some(({ path, collection }) => collection && !J.has(path)));
|
|
2389
|
+
|
|
2390
|
+
// Eligibility is per carrier: ordinary legacy reference-only/all-J predicates keep joined scopes.
|
|
2391
|
+
// Enclosing explicit frames inherit provenance from marked descendants, independently of unit counts.
|
|
2392
|
+
// A view continues issuance on the same map, but checks its own compiled-unit budget (as in 3.12.0).
|
|
2393
|
+
const markClientConstructs = (checked, { aggregation, issued = new Map() } = {}) => {
|
|
2394
|
+
const { gqltype, args, operation, conditions } = checked;
|
|
2395
|
+
const clientJ = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, { gqltype }) : new Set();
|
|
2396
|
+
if (!supportsReferenceEmptiness) return { issued: null, clientJ };
|
|
2397
|
+
const mark = (construct, origin) => {
|
|
2398
|
+
if (!construct || hasScopeMarker(construct)) return;
|
|
2399
|
+
const id = issued.size;
|
|
2091
2400
|
construct[SCOPE_MARKER] = { id };
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2401
|
+
issued.set(id, origin);
|
|
2402
|
+
};
|
|
2403
|
+
for (const { entry, shape } of conditions) {
|
|
2404
|
+
const { construct } = entry;
|
|
2405
|
+
if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2406
|
+
const targets = pathRelations(gqltype, entry.segments).map(({ typeName }) => typeName);
|
|
2407
|
+
if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
|
|
2408
|
+
if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
|
|
2409
|
+
const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
|
|
2410
|
+
mark(construct, origin);
|
|
2411
|
+
const prefix = [];
|
|
2412
|
+
for (const frame of entry.frames) {
|
|
2413
|
+
prefix.push(...frame.path.split('.'));
|
|
2414
|
+
mark(frame.construct, { operator: frame.type, path: prefix.join('.') });
|
|
2415
|
+
}
|
|
2095
2416
|
}
|
|
2417
|
+
const { count } = frameUnits(args, queryFrameContext(gqltype, args, operation));
|
|
2096
2418
|
const limit = getQueryMaxReferenceFrames();
|
|
2097
|
-
if (
|
|
2098
|
-
throw new SimfinityError(`
|
|
2099
|
-
+ 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
|
|
2100
|
-
+ `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2419
|
+
if (count > limit) {
|
|
2420
|
+
throw new SimfinityError(`The client's conditions need ${count} frames and scoped joins in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2101
2421
|
}
|
|
2102
|
-
return
|
|
2422
|
+
return { issued, clientJ };
|
|
2103
2423
|
};
|
|
2104
2424
|
|
|
2105
|
-
//
|
|
2106
|
-
//
|
|
2107
|
-
|
|
2108
|
-
const
|
|
2425
|
+
// Every issued ID must survive on a valid carrier, with no forged or prefilled marker. Marked
|
|
2426
|
+
// descendants may only live inside marked client frames; top-frame collection prefixes keep J.
|
|
2427
|
+
const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
|
|
2428
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2109
2429
|
const found = new Set();
|
|
2110
|
-
for (const
|
|
2430
|
+
for (const entry of collectQueryOperators(args, operation)) {
|
|
2431
|
+
const { construct, operator, path } = entry;
|
|
2111
2432
|
if (!hasScopeMarker(construct)) continue;
|
|
2112
2433
|
const marker = construct[SCOPE_MARKER];
|
|
2113
2434
|
const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
|
|
@@ -2118,6 +2439,17 @@ const assertClientMarkers = (args, operation, issued) => {
|
|
|
2118
2439
|
if (!origin) {
|
|
2119
2440
|
throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
|
|
2120
2441
|
}
|
|
2442
|
+
if (ignoredScalarTerm(gqltype, entry)) {
|
|
2443
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is in ignored scalar filter terms`);
|
|
2444
|
+
}
|
|
2445
|
+
if (entry.frames.some((frame) => !hasScopeMarker(frame.construct))) {
|
|
2446
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
|
|
2447
|
+
}
|
|
2448
|
+
if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
|
|
2449
|
+
for (const relation of pathRelations(gqltype, entry.segments)) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
|
|
2450
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2121
2453
|
found.add(marker.id);
|
|
2122
2454
|
}
|
|
2123
2455
|
for (const [id, origin] of issued) {
|
|
@@ -2138,6 +2470,15 @@ const relationOnPath = (gqltype, segments) => {
|
|
|
2138
2470
|
return found;
|
|
2139
2471
|
};
|
|
2140
2472
|
|
|
2473
|
+
const scopeFilterArgs = (scopeArgs) => {
|
|
2474
|
+
const kept = {};
|
|
2475
|
+
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2476
|
+
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2477
|
+
else if (value != null) assertScopeControlArg(name, value);
|
|
2478
|
+
}
|
|
2479
|
+
return kept;
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2141
2482
|
// The find scope arguments of a type that a client's constructs read through, once per call and type
|
|
2142
2483
|
// (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
|
|
2143
2484
|
// joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
|
|
@@ -2150,11 +2491,7 @@ const clientScopeArgs = async (typeName, context, cache) => {
|
|
|
2150
2491
|
if (getScope(target?.gqltype, 'find')) {
|
|
2151
2492
|
const scopeArgs = {};
|
|
2152
2493
|
await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
|
|
2153
|
-
const kept =
|
|
2154
|
-
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2155
|
-
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2156
|
-
else if (value != null) assertScopeControlArg(name, value);
|
|
2157
|
-
}
|
|
2494
|
+
const kept = scopeFilterArgs(scopeArgs);
|
|
2158
2495
|
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2159
2496
|
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2160
2497
|
if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
|
|
@@ -2163,19 +2500,15 @@ const clientScopeArgs = async (typeName, context, cache) => {
|
|
|
2163
2500
|
return result;
|
|
2164
2501
|
};
|
|
2165
2502
|
|
|
2166
|
-
//
|
|
2167
|
-
//
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
// keys of every position when middleware placed the object at several;
|
|
2171
|
-
// - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
|
|
2172
|
-
// - another operator loses it too, unless its path still crosses or ends at a reference or a
|
|
2173
|
-
// collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
|
|
2174
|
-
// An object that reads references at one position and none at another fails the same way, since one
|
|
2175
|
-
// marker cannot serve both. Every check comes before any target scope runs.
|
|
2503
|
+
// Preflight every final position before running target scopes: union relative keys, reject target
|
|
2504
|
+
// collisions and mixed reads, and retain the compiler's rejection of marked legacy predicates.
|
|
2505
|
+
// J collection keys are null; anchor references remain legacy joins. Keyless carriers lose their
|
|
2506
|
+
// marker. One per-call type cache serves all remaining keys, separately from joined-scope execution.
|
|
2176
2507
|
const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
2508
|
+
const { conditions, args, operation } = checked;
|
|
2509
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2177
2510
|
const marked = new Map();
|
|
2178
|
-
for (const item of
|
|
2511
|
+
for (const item of conditions) {
|
|
2179
2512
|
const { construct } = item.entry;
|
|
2180
2513
|
if (!hasScopeMarker(construct)) continue;
|
|
2181
2514
|
if (!marked.has(construct)) marked.set(construct, []);
|
|
@@ -2187,18 +2520,29 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2187
2520
|
const origin = issued.get(id);
|
|
2188
2521
|
const targets = {};
|
|
2189
2522
|
const unread = [];
|
|
2190
|
-
|
|
2191
|
-
|
|
2523
|
+
const vectors = [];
|
|
2524
|
+
for (const { entry, shape, type } of positions) {
|
|
2525
|
+
const relations = pathRelations(type, entry.relative);
|
|
2526
|
+
if (!shape && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
|
|
2192
2527
|
const relation = relationOnPath(gqltype, entry.segments);
|
|
2193
|
-
if (relation) {
|
|
2194
|
-
|
|
2195
|
-
|
|
2528
|
+
if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2529
|
+
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
2530
|
+
}
|
|
2531
|
+
const rootShorthand = !entry.frames.length && entry.form !== 'quantified' && !shape;
|
|
2532
|
+
const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
|
|
2533
|
+
const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
|
|
2534
|
+
const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
|
|
2535
|
+
.filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2536
|
+
.map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
|
|
2537
|
+
for (const [key, target] of Object.entries(keys)) {
|
|
2538
|
+
if (Object.hasOwn(targets, key) && targets[key] !== target) {
|
|
2539
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} uses ${key} for different scope targets`);
|
|
2196
2540
|
}
|
|
2197
|
-
|
|
2198
|
-
}
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2541
|
+
targets[key] = target;
|
|
2542
|
+
}
|
|
2543
|
+
if (!Object.keys(keys).length) unread.push(entry.path);
|
|
2544
|
+
for (const relation of relations) if (!relation.collection && relation.insideList && Object.hasOwn(keys, relation.path)) {
|
|
2545
|
+
vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
|
|
2202
2546
|
}
|
|
2203
2547
|
}
|
|
2204
2548
|
const paths = Object.keys(targets);
|
|
@@ -2206,16 +2550,16 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2206
2550
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
|
|
2207
2551
|
+ `references or collections and where it reads none (${unread.join(', ')})`);
|
|
2208
2552
|
}
|
|
2209
|
-
fills.push({ construct, id, targets: paths.length ? targets : null });
|
|
2553
|
+
fills.push({ construct, id, targets: paths.length ? targets : null, vectors });
|
|
2210
2554
|
}
|
|
2211
2555
|
const cache = new Map();
|
|
2212
|
-
for (const { construct, id, targets } of fills) {
|
|
2213
|
-
if (!targets) {
|
|
2214
|
-
delete construct[SCOPE_MARKER];
|
|
2215
|
-
continue;
|
|
2216
|
-
}
|
|
2556
|
+
for (const { construct, id, targets, vectors } of fills) {
|
|
2557
|
+
if (!targets) { delete construct[SCOPE_MARKER]; continue; }
|
|
2217
2558
|
const scopes = {};
|
|
2218
|
-
for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
|
|
2559
|
+
for (const [path, typeName] of Object.entries(targets)) scopes[path] = typeName === null ? null : await clientScopeArgs(typeName, context, cache);
|
|
2560
|
+
for (const relation of vectors) if (scopes[relation.path] !== null) {
|
|
2561
|
+
throw forbiddenPath(`Query path ${relation.absolute} enters ${relation.typeName}, whose find scope restricts the relation ${relation.owner}.${relation.fieldName} inside a list`);
|
|
2562
|
+
}
|
|
2219
2563
|
construct[SCOPE_MARKER] = { id, scopes };
|
|
2220
2564
|
}
|
|
2221
2565
|
};
|
|
@@ -2225,8 +2569,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2225
2569
|
// the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
|
|
2226
2570
|
// Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
|
|
2227
2571
|
// the adapter after as many promise jobs as before.
|
|
2228
|
-
const settleClientArgs = (gqltype, params, operation, issued) => {
|
|
2229
|
-
if (issued) assertClientMarkers(params.args, operation, issued);
|
|
2572
|
+
const settleClientArgs = (gqltype, params, operation, { issued, clientJ }) => {
|
|
2573
|
+
if (issued) assertClientMarkers(gqltype, params.args, operation, issued, clientJ);
|
|
2230
2574
|
assertStoredQueryPaths(gqltype, params.args, operation);
|
|
2231
2575
|
const checked = assertQueryConditions(gqltype, params.args, operation);
|
|
2232
2576
|
return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
|
|
@@ -2304,6 +2648,29 @@ const loadReference = (type, id, requiredId, context) => {
|
|
|
2304
2648
|
});
|
|
2305
2649
|
};
|
|
2306
2650
|
|
|
2651
|
+
// The query arguments whose get_by_id scope function threw in findVisibleById. The error propagates
|
|
2652
|
+
// as it was thrown; the conflict path (reportEarlyConflict) tells it from errors of the gates and of
|
|
2653
|
+
// adapter.find this way. Each caller (resolveById and reportEarlyConflict) allocates fresh args
|
|
2654
|
+
// and calls findVisibleById exactly once with that object; never reuse marked args for another read.
|
|
2655
|
+
const failedByIdScopes = new WeakSet();
|
|
2656
|
+
|
|
2657
|
+
// The record that the type's get_by_id scope shows the caller, or null. `args` holds an `id` EQ
|
|
2658
|
+
// condition, to which the scope adds its own; the gates check them as for every read, and adapter.find
|
|
2659
|
+
// reads in `session`, pinned to `requiredId`. get_by_id middleware is not run here; resolveById runs it
|
|
2660
|
+
// first.
|
|
2661
|
+
const findVisibleById = async (type, args, session, context, requiredId) => {
|
|
2662
|
+
try {
|
|
2663
|
+
await executeScope({ type, args, operation: 'get_by_id', context });
|
|
2664
|
+
} catch (error) {
|
|
2665
|
+
failedByIdScopes.add(args);
|
|
2666
|
+
throw error;
|
|
2667
|
+
}
|
|
2668
|
+
assertStoredQueryPaths(type.gqltype, args, 'find');
|
|
2669
|
+
assertQueryConditions(type.gqltype, args, 'find');
|
|
2670
|
+
const results = await adapter.find(type.model, type.gqltype, args, session, { requiredId, context });
|
|
2671
|
+
return results[0] || null;
|
|
2672
|
+
};
|
|
2673
|
+
|
|
2307
2674
|
// `requiredId` marks a generated single-reference read. Middleware still runs per call; then, when the
|
|
2308
2675
|
// adapter reads by ID in batches, and unless the type has a get_by_id scope or middleware changed the
|
|
2309
2676
|
// ID, the read joins the request's batch.
|
|
@@ -2317,12 +2684,7 @@ const resolveById = async (type, args, context, requiredId) => {
|
|
|
2317
2684
|
}
|
|
2318
2685
|
return adapter.getById(type.model, args.id, null, { requiredId, context });
|
|
2319
2686
|
}
|
|
2320
|
-
|
|
2321
|
-
await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
|
|
2322
|
-
assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
|
|
2323
|
-
assertQueryConditions(type.gqltype, queryArgs, 'find');
|
|
2324
|
-
const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
|
|
2325
|
-
return results[0] || null;
|
|
2687
|
+
return findVisibleById(type, { id: { operator: 'EQ', value: args.id } }, null, context, requiredId);
|
|
2326
2688
|
};
|
|
2327
2689
|
|
|
2328
2690
|
// Batched collection reads. A generated collection field reads its children with one
|
|
@@ -2713,10 +3075,13 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2713
3075
|
: `mutation that takes no input or an input of its own, since getInputType(${type.gqltype.name}) `
|
|
2714
3076
|
+ 'has no fields.'));
|
|
2715
3077
|
}
|
|
3078
|
+
// A protected type is deleted at the version the client read (#98).
|
|
3079
|
+
const deleteArgs = { id: { type: new GraphQLNonNull(GraphQLID) } };
|
|
3080
|
+
if (isProtected(type.gqltype)) deleteArgs.version = { type: new GraphQLNonNull(RecordVersion) };
|
|
2716
3081
|
rootQueryArgs.fields[`delete${type.simpleEntityEndpointName}`] = {
|
|
2717
3082
|
type: type.gqltype,
|
|
2718
3083
|
description: 'delete',
|
|
2719
|
-
args:
|
|
3084
|
+
args: deleteArgs,
|
|
2720
3085
|
extensions: { simfinityMutation: { typeName: type.gqltype.name, operation: operations.DELETE } },
|
|
2721
3086
|
async resolve(parent, args, context) {
|
|
2722
3087
|
const params = {
|
|
@@ -2727,8 +3092,10 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2727
3092
|
};
|
|
2728
3093
|
|
|
2729
3094
|
await executeMiddleware(params);
|
|
3095
|
+
// A protected delete takes the version as middleware left it.
|
|
3096
|
+
const target = isProtected(type.gqltype) ? { id: args.id, version: args.version } : args.id;
|
|
2730
3097
|
return executeOperation(type.model, type.gqltype, type.controller,
|
|
2731
|
-
|
|
3098
|
+
target, operations.DELETE, null, null, context, null);
|
|
2732
3099
|
},
|
|
2733
3100
|
};
|
|
2734
3101
|
}
|
|
@@ -2820,7 +3187,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
2820
3187
|
// without one still reaches the adapter after as many promise jobs as before it was shared.
|
|
2821
3188
|
const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
|
|
2822
3189
|
const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
|
|
2823
|
-
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
|
|
3190
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation), { aggregation: args.aggregation });
|
|
2824
3191
|
const params = entry === undefined
|
|
2825
3192
|
? { type, args, operation, context }
|
|
2826
3193
|
: { type, args, operation, entry, context };
|
|
@@ -2869,13 +3236,13 @@ const splitViewArgs = (paramNames, args) => {
|
|
|
2869
3236
|
|
|
2870
3237
|
const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
|
|
2871
3238
|
|
|
2872
|
-
// The groups a view's `where` returned are plain objects whose
|
|
3239
|
+
// The groups a view's `where` returned are plain objects whose condition and Boolean/quantified lists, when set,
|
|
2873
3240
|
// are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
|
|
2874
3241
|
// skips anything else silently, so this comes before their paths are read. Only the GraphQL input
|
|
2875
|
-
// fields are structural keys; condition values stay opaque.
|
|
2876
|
-
// marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
2877
|
-
const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
2878
|
-
const groupFields = QLFilterGroup.getFields();
|
|
3242
|
+
// fields are structural keys; condition values stay opaque. Conditions and quantified groups never
|
|
3243
|
+
// carry a client scope marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
3244
|
+
const assertViewGroupShapes = (view, groups, ancestors = new Set(), quantified = false) => {
|
|
3245
|
+
const groupFields = (quantified ? QLQuantifiedGroup : QLFilterGroup).getFields();
|
|
2879
3246
|
const conditionFields = QLFilterCondition.getFields();
|
|
2880
3247
|
for (const group of groups) {
|
|
2881
3248
|
if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
|
|
@@ -2883,7 +3250,11 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
|
2883
3250
|
for (const key of Object.keys(group)) {
|
|
2884
3251
|
if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
|
|
2885
3252
|
}
|
|
3253
|
+
if (quantified && (typeof group.path !== 'string' || group.path.length === 0)) {
|
|
3254
|
+
throw invalidWhere(view, 'returned a quantified group without a non-empty path');
|
|
3255
|
+
}
|
|
2886
3256
|
for (const key of Object.keys(groupFields)) {
|
|
3257
|
+
if (key === 'path') continue;
|
|
2887
3258
|
if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
|
|
2888
3259
|
}
|
|
2889
3260
|
for (const condition of group.conditions ?? []) {
|
|
@@ -2899,6 +3270,7 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
|
2899
3270
|
}
|
|
2900
3271
|
ancestors.add(group);
|
|
2901
3272
|
assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
|
|
3273
|
+
for (const key of QUANTIFIED_LISTS) assertViewGroupShapes(view, group[key] ?? [], ancestors, true);
|
|
2902
3274
|
ancestors.delete(group);
|
|
2903
3275
|
}
|
|
2904
3276
|
};
|
|
@@ -3317,6 +3689,108 @@ const warnUndeclaredVirtualFields = () => {
|
|
|
3317
3689
|
}
|
|
3318
3690
|
};
|
|
3319
3691
|
|
|
3692
|
+
// The types a model stores: the registered types and the embedded types they reach at any depth,
|
|
3693
|
+
// readOnly fields included, as assertPlainConnectionFields visits them.
|
|
3694
|
+
const collectStoredTypes = () => {
|
|
3695
|
+
const stored = new Set();
|
|
3696
|
+
const visit = (gqltype) => {
|
|
3697
|
+
if (stored.has(gqltype)) return;
|
|
3698
|
+
stored.add(gqltype);
|
|
3699
|
+
for (const field of Object.values(gqltype.getFields())) {
|
|
3700
|
+
const named = field.extensions?.relation?.embedded && getNamedType(field.type);
|
|
3701
|
+
if (named instanceof GraphQLObjectType) visit(named);
|
|
3702
|
+
}
|
|
3703
|
+
};
|
|
3704
|
+
for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
|
|
3705
|
+
return [...stored];
|
|
3706
|
+
};
|
|
3707
|
+
|
|
3708
|
+
const invalidModel = (message) => new SimfinityError(message, 'INVALID_MODEL', 400);
|
|
3709
|
+
|
|
3710
|
+
// Optimistic concurrency (#98), declared per type with `extensions.concurrency`. These checks run in
|
|
3711
|
+
// this order, after stored identities are known and before adapter.prepare(), so a rejected schema
|
|
3712
|
+
// changes nothing. `applicationTypes` are the roots of the schema's types. Returns the registrations of
|
|
3713
|
+
// the protected types and whether the included roots reserve the version names.
|
|
3714
|
+
const assertConcurrencyDeclarations = (applicationTypes) => {
|
|
3715
|
+
const registrations = Object.values(typesDict.types);
|
|
3716
|
+
// The shape of every declaration.
|
|
3717
|
+
const protectedTypes = registrations.filter(({ gqltype }) => concurrencyDeclared(gqltype));
|
|
3718
|
+
// Generated inputs, models and stored data follow the protection a type had when the first schema
|
|
3719
|
+
// containing it was created, and an injected field is never removed, so neither can change later.
|
|
3720
|
+
// Inputs or a model that an earlier createSchema() built without the field also fix protection,
|
|
3721
|
+
// even if the call failed afterwards; a model supplied to connect() does not.
|
|
3722
|
+
for (const registration of registrations) {
|
|
3723
|
+
const { gqltype } = registration;
|
|
3724
|
+
const declared = protectedTypes.includes(registration);
|
|
3725
|
+
const builtWithoutField = !isProtected(gqltype)
|
|
3726
|
+
&& (!!registration.inputType || generatedModels.has(registration.model));
|
|
3727
|
+
if (declared && protectionStatus.get(gqltype) === false) {
|
|
3728
|
+
throw invalidModel(`${gqltype.name} declares optimistic concurrency after a schema was created without it; `
|
|
3729
|
+
+ `declare it before the first createSchema() that contains ${gqltype.name}`);
|
|
3730
|
+
}
|
|
3731
|
+
if (declared && builtWithoutField) {
|
|
3732
|
+
throw invalidModel(`${gqltype.name} declares optimistic concurrency after its inputs or generated model were built without it; `
|
|
3733
|
+
+ `declare it before the first createSchema() attempt that builds ${gqltype.name}`);
|
|
3734
|
+
}
|
|
3735
|
+
if (!declared && isProtected(gqltype)) {
|
|
3736
|
+
throw invalidModel(`${gqltype.name} no longer declares optimistic concurrency, but Simfinity already added its `
|
|
3737
|
+
+ 'version field; keep extensions.concurrency on a type once it has that field');
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
// Another runtime that registers a protected type injects its field when that runtime creates a
|
|
3741
|
+
// schema, which would change a schema already built here, so this schema must register every
|
|
3742
|
+
// protected type it contains, whatever the adapter supports.
|
|
3743
|
+
const reachedTypes = collectOutputTypes(applicationTypes);
|
|
3744
|
+
for (const type of reachedTypes) {
|
|
3745
|
+
if (type instanceof GraphQLObjectType && typesDict.types[type.name]?.gqltype !== type && isProtectedType(type)) {
|
|
3746
|
+
throw invalidModel(`${type.name} declares optimistic concurrency, so a schema that contains it must register it `
|
|
3747
|
+
+ 'in this runtime, with connect() or as a relation target');
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
// An embedded value's changes belong to the revision of the record that embeds it, wherever a model
|
|
3751
|
+
// stores it.
|
|
3752
|
+
const storedTypes = collectStoredTypes();
|
|
3753
|
+
const storedFields = storedTypes.flatMap((gqltype) => Object.entries(gqltype.getFields())
|
|
3754
|
+
.map(([fieldName, field]) => ({ gqltype, fieldName, field })));
|
|
3755
|
+
for (const { gqltype, fieldName, field } of storedFields) {
|
|
3756
|
+
const named = field.extensions?.relation?.embedded && getNamedType(field.type);
|
|
3757
|
+
if (named instanceof GraphQLObjectType && isProtectedType(named)) {
|
|
3758
|
+
throw invalidModel(versionEmbeddedMisuse(named.name, `${gqltype.name}.${fieldName}`));
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
// Only an entity has a record of its own whose version can advance.
|
|
3762
|
+
const nonEntity = protectedTypes.find(({ storedIdentity }) => storedIdentity !== true);
|
|
3763
|
+
if (nonEntity) {
|
|
3764
|
+
throw invalidModel(`${nonEntity.gqltype.name} declares optimistic concurrency, so it must be an entity: register it `
|
|
3765
|
+
+ 'with connect() or make it a relation target');
|
|
3766
|
+
}
|
|
3767
|
+
// Nothing else may be stored, or link records, under the version field's name.
|
|
3768
|
+
for (const { gqltype } of protectedTypes) {
|
|
3769
|
+
const collision = versionFieldCollision(gqltype);
|
|
3770
|
+
if (collision) throw invalidModel(collision);
|
|
3771
|
+
}
|
|
3772
|
+
for (const { gqltype, fieldName, field } of storedFields) {
|
|
3773
|
+
const collision = versionConnectionCollision(gqltype, fieldName, field);
|
|
3774
|
+
if (collision) throw invalidModel(collision);
|
|
3775
|
+
}
|
|
3776
|
+
// RecordVersion types managed version fields only, with or without protected types; registered
|
|
3777
|
+
// operations may still use it in their own input and output types.
|
|
3778
|
+
for (const gqltype of storedTypes) {
|
|
3779
|
+
const misuse = recordVersionFieldMisuse(gqltype);
|
|
3780
|
+
if (misuse) throw invalidModel(misuse);
|
|
3781
|
+
}
|
|
3782
|
+
const reserveVersionNames = protectedTypes.some(({ gqltype }) => reachedTypes.includes(gqltype));
|
|
3783
|
+
if (protectedTypes.length && !writeFeatures.optimisticConcurrency) {
|
|
3784
|
+
throw invalidModel(`The database adapter does not support optimistic concurrency, which ${protectedTypes[0].gqltype.name} `
|
|
3785
|
+
+ 'declares (extensions.concurrency)');
|
|
3786
|
+
}
|
|
3787
|
+
// A clash that only generated types or the fields of an input object reach is found when the schema
|
|
3788
|
+
// is constructed.
|
|
3789
|
+
const clash = reserveVersionNames && versionTypeClash(applicationTypes, { inputFields: false });
|
|
3790
|
+
if (clash) throw reservedVersionTypeNameError(clash);
|
|
3791
|
+
return { protectedTypes, reserveVersionNames };
|
|
3792
|
+
};
|
|
3793
|
+
|
|
3320
3794
|
const getRegistrations = () => Object.values(typesDict.types);
|
|
3321
3795
|
|
|
3322
3796
|
const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
|
|
@@ -3324,7 +3798,8 @@ const includedOperationTypes = (registrations, includedOperations) => Object.ent
|
|
|
3324
3798
|
.flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
|
|
3325
3799
|
|
|
3326
3800
|
// Application types that a schema with these allowlists contains, whatever else it reaches: the
|
|
3327
|
-
// endpoint types of its queries and mutations,
|
|
3801
|
+
// endpoint types of its queries and mutations, the input and output types of its custom operations,
|
|
3802
|
+
// and the scalar/enum parameters of included named views.
|
|
3328
3803
|
const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
|
|
3329
3804
|
...Object.values(typesDict.types)
|
|
3330
3805
|
.filter((type) => type.endpoint
|
|
@@ -3333,6 +3808,9 @@ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, inc
|
|
|
3333
3808
|
.map((type) => type.gqltype),
|
|
3334
3809
|
...includedOperationTypes(registeredMutations, includedCustomMutations),
|
|
3335
3810
|
...includedOperationTypes(registeredQueries, includedCustomQueries),
|
|
3811
|
+
...Object.values(registeredViews)
|
|
3812
|
+
.filter(({ type }) => !shouldNotBeIncludedInSchema(includedQueryTypes, type))
|
|
3813
|
+
.flatMap(({ params }) => Object.values(params).map(({ type }) => getNamedType(type))),
|
|
3336
3814
|
];
|
|
3337
3815
|
|
|
3338
3816
|
const queryNameConflict = (name) => new SimfinityError(
|
|
@@ -3374,7 +3852,9 @@ const assertViewsAvailable = (includedQueryTypes, includedCustomQueries) => {
|
|
|
3374
3852
|
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(paramName) || paramName.startsWith('__')) {
|
|
3375
3853
|
throw invalidView(`${name} param ${paramName} requires a valid GraphQL name that does not start with __`);
|
|
3376
3854
|
}
|
|
3377
|
-
|
|
3855
|
+
// A protected type's managed version is injected later in createSchema(), but its name is
|
|
3856
|
+
// already unavailable to view params on the first schema, as on every subsequent schema.
|
|
3857
|
+
if (Object.hasOwn(fields, paramName) || (paramName === 'version' && isProtectedType(type))) {
|
|
3378
3858
|
throw invalidView(`${name} param ${paramName} has the name of a field of ${type.name}`);
|
|
3379
3859
|
}
|
|
3380
3860
|
if (LIST_QUERY_ARGS.includes(paramName)) {
|
|
@@ -3428,16 +3908,21 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3428
3908
|
// An application type with the name of a field metadata type that an earlier schema fixed; one that
|
|
3429
3909
|
// only generated types or the fields of an input object reach is found when the schema is
|
|
3430
3910
|
// constructed.
|
|
3431
|
-
|
|
3432
|
-
includedCustomMutations, includedCustomQueries)
|
|
3911
|
+
const applicationTypes = includedApplicationTypes(includedQueryTypes, includedMutationTypes,
|
|
3912
|
+
includedCustomMutations, includedCustomQueries);
|
|
3913
|
+
assertFieldMetadataNamesFree(applicationTypes);
|
|
3433
3914
|
const referencedTypes = markReferencedTypesForModelGeneration();
|
|
3434
3915
|
// Entities, which keep their own stored identity: connect() types and relation targets.
|
|
3435
3916
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
3436
3917
|
typeInfo.storedIdentity = typeInfo.endpoint === true || referencedTypes.has(typeInfo);
|
|
3437
3918
|
});
|
|
3919
|
+
const { protectedTypes, reserveVersionNames } = assertConcurrencyDeclarations(applicationTypes);
|
|
3438
3920
|
if (adapter.prepare) {
|
|
3439
3921
|
adapter.prepare(getRegistrations(), { createCollection: !preventCollectionCreation });
|
|
3440
3922
|
}
|
|
3923
|
+
// prepare() describes protected types from their declaration. Models, inputs, resolvers and query
|
|
3924
|
+
// arguments are built from here on, so they all see the field.
|
|
3925
|
+
protectedTypes.forEach(({ gqltype }) => injectVersionField(gqltype));
|
|
3441
3926
|
|
|
3442
3927
|
Object.values(typesDict.types).forEach((typeInfo) => {
|
|
3443
3928
|
if (typeInfo.gqltype && !typeInfo.model) {
|
|
@@ -3448,6 +3933,8 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3448
3933
|
} else if (typeInfo.needsModel) {
|
|
3449
3934
|
typeInfo.model = adapter.createModel(typeInfo.gqltype, null, { createCollection: false });
|
|
3450
3935
|
}
|
|
3936
|
+
const { model } = typeInfo;
|
|
3937
|
+
if (model !== null && (typeof model === 'object' || typeof model === 'function')) generatedModels.add(model);
|
|
3451
3938
|
}
|
|
3452
3939
|
});
|
|
3453
3940
|
|
|
@@ -3492,7 +3979,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3492
3979
|
includedCustomQueries);
|
|
3493
3980
|
}
|
|
3494
3981
|
|
|
3495
|
-
const schema = constructSchema(query, mutation);
|
|
3982
|
+
const schema = constructSchema(query, mutation, reserveVersionNames);
|
|
3496
3983
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
3497
3984
|
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
3498
3985
|
schemaTypes.forEach((type) => {
|
|
@@ -3500,6 +3987,10 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3500
3987
|
runtimeBoundTypes.set(type, runtimeIdentity);
|
|
3501
3988
|
}
|
|
3502
3989
|
});
|
|
3990
|
+
// The schema exists, so each registered type's protection is fixed from now on.
|
|
3991
|
+
Object.values(typesDict.types).forEach(({ gqltype }) => {
|
|
3992
|
+
if (!protectionStatus.has(gqltype)) protectionStatus.set(gqltype, isProtected(gqltype));
|
|
3993
|
+
});
|
|
3503
3994
|
return schema;
|
|
3504
3995
|
};
|
|
3505
3996
|
|
|
@@ -3721,6 +4212,8 @@ const connect = (model, gqltype, simpleEntityEndpointName,
|
|
|
3721
4212
|
};
|
|
3722
4213
|
assertNotBoundElsewhere(gqltype);
|
|
3723
4214
|
validateScope(gqltype);
|
|
4215
|
+
// The declaration's shape only; it reads extensions, never the fields (#98).
|
|
4216
|
+
concurrencyDeclared(gqltype);
|
|
3724
4217
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
3725
4218
|
|
|
3726
4219
|
invalidateCollectionInputs(gqltype);
|
|
@@ -3751,6 +4244,7 @@ const addNoEndpointType = (gqltype) => {
|
|
|
3751
4244
|
gqltype, endpoint: false, model: null, needsModel,
|
|
3752
4245
|
};
|
|
3753
4246
|
validateScope(gqltype);
|
|
4247
|
+
concurrencyDeclared(gqltype);
|
|
3754
4248
|
if (adapter.validateRegistration) adapter.validateRegistration(registration);
|
|
3755
4249
|
|
|
3756
4250
|
invalidateCollectionInputs(gqltype);
|
|
@@ -3764,7 +4258,9 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
|
|
|
3764
4258
|
|
|
3765
4259
|
// `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
|
|
3766
4260
|
// A field named like a generated argument gets no top-level filter either; it is reported once.
|
|
3767
|
-
// A virtual field is not part of the query language, so it gets no filter at all
|
|
4261
|
+
// A virtual field is not part of the query language, so it gets no filter at all, and neither does the
|
|
4262
|
+
// version field of a protected type (#98): a MongoDB record without a stored version reads as 1, which
|
|
4263
|
+
// a filter could not match.
|
|
3768
4264
|
const createArgsForQuery = (gqltype, omit = null) => {
|
|
3769
4265
|
const argsObject = {};
|
|
3770
4266
|
const argTypes = gqltype.getFields();
|
|
@@ -3777,7 +4273,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
|
|
|
3777
4273
|
}
|
|
3778
4274
|
|
|
3779
4275
|
for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
|
|
3780
|
-
if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
|
|
4276
|
+
if (fieldEntryName === omit || isVirtualField(fieldEntry) || isVersionField(fieldEntry)) continue;
|
|
3781
4277
|
let filterType = null;
|
|
3782
4278
|
|
|
3783
4279
|
if (fieldEntry.type instanceof GraphQLScalarType
|