@simtlix/simfinity-core 3.13.0 → 3.15.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 +28 -3
- package/package.json +12 -2
- package/src/after-commit.js +83 -0
- package/src/const/QLQuantifier.js +21 -0
- package/src/index.js +1 -0
- package/src/introspection-config.js +17 -0
- package/src/introspection-state.js +10 -0
- package/src/introspection.js +5 -3
- 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/runtime.js +291 -137
- package/src/schema-registrations.js +18 -0
- package/src/transaction-callbacks.js +34 -0
- package/types/index.d.ts +132 -19
- package/types/introspection.d.ts +4 -0
package/src/runtime.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
configureQueryLimits, getQueryMaxPageSize, getQueryMaxReferenceFrames, paginationStages,
|
|
3
3
|
} from './query-limits.js';
|
|
4
4
|
import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
|
|
5
|
+
import { createAfterCommit } from './after-commit.js';
|
|
5
6
|
import {
|
|
6
7
|
GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
|
|
7
8
|
GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
|
|
@@ -10,17 +11,20 @@ import {
|
|
|
10
11
|
} from 'graphql';
|
|
11
12
|
|
|
12
13
|
import SimfinityError from './errors/simfinity.error.js';
|
|
14
|
+
import { captureSchemaRegistrations } from './schema-registrations.js';
|
|
13
15
|
import InternalServerError from './errors/internal-server.error.js';
|
|
14
16
|
import { ForbiddenError } from './auth/errors.js';
|
|
15
17
|
import QLOperator from './const/QLOperator.js';
|
|
18
|
+
import QLQuantifier, { QUANTIFIED_LISTS } from './const/QLQuantifier.js';
|
|
19
|
+
import { aggregateJoinPrefixes, assertSortSyntax, frameUnits, validateQuantifiedGroups } from './query-frames.js';
|
|
16
20
|
import QLValue from './const/QLValue.js';
|
|
17
21
|
import QLSort from './const/QLSort.js';
|
|
18
22
|
import {
|
|
19
|
-
collectQueryOperators, collectQueryPathEntries,
|
|
23
|
+
collectQueryOperators, collectQueryPathEntries, walkQueryPath,
|
|
20
24
|
} from './query-plan.js';
|
|
21
25
|
import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
|
|
22
26
|
import {
|
|
23
|
-
assertEmptinessPath, describeEmptiness,
|
|
27
|
+
assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
|
|
24
28
|
} from './query-emptiness.js';
|
|
25
29
|
import { relationFieldOwners } from './relation-owners.js';
|
|
26
30
|
import { isThenable } from './auth/thenable.js';
|
|
@@ -181,7 +185,7 @@ const assertScopeControlArg = (name, value) => {
|
|
|
181
185
|
};
|
|
182
186
|
|
|
183
187
|
// Moves a joined type's scope group below the relation path that entered it.
|
|
184
|
-
const prefixScopeGroup = (group, prefix) => {
|
|
188
|
+
const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
|
|
185
189
|
if (!group || typeof group !== 'object' || Array.isArray(group)) {
|
|
186
190
|
throw new SimfinityError('Expected a scope filter group object', 'INVALID_FILTER_VALUE', 400);
|
|
187
191
|
}
|
|
@@ -193,12 +197,15 @@ const prefixScopeGroup = (group, prefix) => {
|
|
|
193
197
|
value: condition?.value,
|
|
194
198
|
}));
|
|
195
199
|
}
|
|
196
|
-
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix));
|
|
197
|
-
if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix));
|
|
200
|
+
if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
201
|
+
if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
|
|
202
|
+
for (const key of QUANTIFIED_LISTS) if (group[key] != null) {
|
|
203
|
+
prefixed[key] = group[key].map((item) => ({ ...copyQuantifiedGroup(item), path: `${prefix}.${item.path}` }));
|
|
204
|
+
}
|
|
198
205
|
return prefixed;
|
|
199
206
|
};
|
|
200
207
|
|
|
201
|
-
const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
208
|
+
const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
|
|
202
209
|
const conditions = [];
|
|
203
210
|
const groups = [];
|
|
204
211
|
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
@@ -207,10 +214,12 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
|
|
|
207
214
|
assertScopeControlArg(name, value);
|
|
208
215
|
continue;
|
|
209
216
|
}
|
|
210
|
-
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
|
|
217
|
+
if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)));
|
|
211
218
|
else if (name === 'OR') {
|
|
212
219
|
const branches = [].concat(value);
|
|
213
|
-
if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix)) });
|
|
220
|
+
if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)) });
|
|
221
|
+
} else if (value.quantifier != null) {
|
|
222
|
+
groups.push({ [value.quantifier]: [{ path: `${prefix}.${name}`, conditions: (value.terms ?? []).map(({ path, ...term }) => ({ field: path, ...term })) }] });
|
|
214
223
|
} else if (Object.hasOwn(value, 'terms')) {
|
|
215
224
|
if (!Array.isArray(value.terms) || !value.terms.length) {
|
|
216
225
|
throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
|
|
@@ -537,6 +546,7 @@ const attemptInputs = (input, type) => {
|
|
|
537
546
|
};
|
|
538
547
|
|
|
539
548
|
export const createRuntime = (adapter) => {
|
|
549
|
+
const afterCommit = createAfterCommit(adapter);
|
|
540
550
|
if (!adapter) {
|
|
541
551
|
throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
|
|
542
552
|
}
|
|
@@ -545,7 +555,7 @@ export const createRuntime = (adapter) => {
|
|
|
545
555
|
// IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
|
|
546
556
|
// conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
|
|
547
557
|
const {
|
|
548
|
-
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
|
|
558
|
+
operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
|
|
549
559
|
} = resolveQueryFeatures(adapter.queryFeatures);
|
|
550
560
|
const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
|
|
551
561
|
// The write features the adapter implements, read once: with optimisticConcurrency, schemas may
|
|
@@ -697,6 +707,7 @@ const QLTypeFilterExpression = new GraphQLInputObjectType({
|
|
|
697
707
|
name: 'QLTypeFilterExpression',
|
|
698
708
|
fields: () => ({
|
|
699
709
|
terms: { type: new GraphQLList(QLTypeFilter) },
|
|
710
|
+
quantifier: { type: QLQuantifier },
|
|
700
711
|
}),
|
|
701
712
|
});
|
|
702
713
|
|
|
@@ -710,9 +721,25 @@ const QLFilterCondition = new GraphQLInputObjectType({
|
|
|
710
721
|
}),
|
|
711
722
|
});
|
|
712
723
|
|
|
724
|
+
const QLQuantifiedGroup = new GraphQLInputObjectType({
|
|
725
|
+
name: 'QLQuantifiedGroup',
|
|
726
|
+
fields: () => ({
|
|
727
|
+
path: { type: new GraphQLNonNull(GraphQLString) },
|
|
728
|
+
AND: { type: new GraphQLList(QLFilterGroup) },
|
|
729
|
+
OR: { type: new GraphQLList(QLFilterGroup) },
|
|
730
|
+
ANY: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
731
|
+
ALL: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
732
|
+
NONE: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
733
|
+
conditions: { type: new GraphQLList(QLFilterCondition) },
|
|
734
|
+
}),
|
|
735
|
+
});
|
|
736
|
+
|
|
713
737
|
const QLFilterGroup = new GraphQLInputObjectType({
|
|
714
738
|
name: 'QLFilterGroup',
|
|
715
739
|
fields: () => ({
|
|
740
|
+
ANY: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
741
|
+
ALL: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
742
|
+
NONE: { type: new GraphQLList(QLQuantifiedGroup) },
|
|
716
743
|
AND: { type: new GraphQLList(QLFilterGroup) },
|
|
717
744
|
OR: { type: new GraphQLList(QLFilterGroup) },
|
|
718
745
|
conditions: { type: new GraphQLList(QLFilterCondition) },
|
|
@@ -1483,6 +1510,7 @@ const assertRecordVersion = async (gqltype, id, record, expectedVersion, advance
|
|
|
1483
1510
|
// deletes it only at that version, and a record it deleted at another one, which an adapter wrapper
|
|
1484
1511
|
// that drops the options would delete, fails with DATABASE_ERROR and rolls back with the transaction.
|
|
1485
1512
|
const onDelete = async (Model, controller, id, session, context, current, precondition = null) => {
|
|
1513
|
+
afterCommit.validate(controller);
|
|
1486
1514
|
const hasHook = !!(controller && controller.onDelete);
|
|
1487
1515
|
let currentObject = current;
|
|
1488
1516
|
if (currentObject === undefined && (hasHook || !readByIdIsInert(Model))) {
|
|
@@ -1601,6 +1629,7 @@ const setsVersion = (update) => [update, ...Object.entries(update)
|
|
|
1601
1629
|
// cannot set the version, and a result that did not advance it fails with DATABASE_ERROR.
|
|
1602
1630
|
const onUpdateSubject = async (Model, gqltype, controller, args, session, linkToParent, context,
|
|
1603
1631
|
recordChecked = false, { expectedVersion, observedVersion, record } = {}) => {
|
|
1632
|
+
afterCommit.validate(controller);
|
|
1604
1633
|
const versioned = isProtected(gqltype);
|
|
1605
1634
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'UPDATE', session);
|
|
1606
1635
|
const objectId = args.id;
|
|
@@ -1688,6 +1717,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
|
|
|
1688
1717
|
// A protected type's version is captured before the action can change `args`, and checked on the
|
|
1689
1718
|
// plain pre-read record before the from-state check, which reads `state` from that record (#98).
|
|
1690
1719
|
const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
|
|
1720
|
+
afterCommit.validate(controller);
|
|
1691
1721
|
const versioned = isProtected(gqltype);
|
|
1692
1722
|
const expectedVersion = versioned ? assertVersionInput(args.version) : undefined;
|
|
1693
1723
|
const storedModel = versioned
|
|
@@ -1714,6 +1744,7 @@ const onStateChanged = async (Model, gqltype, controller, args, session, actionF
|
|
|
1714
1744
|
};
|
|
1715
1745
|
|
|
1716
1746
|
const onSaveObject = async (Model, gqltype, controller, args, session, linkToParent, context) => {
|
|
1747
|
+
afterCommit.validate(controller);
|
|
1717
1748
|
const materializedModel = await materializeModel(args, gqltype, linkToParent, 'CREATE', session);
|
|
1718
1749
|
if (typesDict.types[gqltype.name].stateMachine) {
|
|
1719
1750
|
materializedModel.modelArgs.state = stateValue(
|
|
@@ -1752,9 +1783,13 @@ const saveObject = async (typeName, args, session, context) => {
|
|
|
1752
1783
|
const type = typesDict.types[typeName];
|
|
1753
1784
|
assertCollectionWrites(type.gqltype, args);
|
|
1754
1785
|
const snapshot = cloneInput(args, type.inputType);
|
|
1755
|
-
return adapter.withTransaction(session, (transaction) =>
|
|
1756
|
-
|
|
1757
|
-
|
|
1786
|
+
return adapter.withTransaction(session, async (transaction) => {
|
|
1787
|
+
const attemptArgs = cloneInput(snapshot, type.inputType);
|
|
1788
|
+
const record = await onSaveObject(type.model, type.gqltype, type.controller, attemptArgs, transaction, null, context);
|
|
1789
|
+
afterCommit.queue({ typeName, controller: type.controller, operation: operations.SAVE,
|
|
1790
|
+
record, args: attemptArgs, context, session: transaction });
|
|
1791
|
+
return record;
|
|
1792
|
+
}, type.model);
|
|
1758
1793
|
};
|
|
1759
1794
|
|
|
1760
1795
|
// Counts the added, updated and deleted entries of referenced collections, at every nesting level that
|
|
@@ -1836,7 +1871,7 @@ const preReadTopLevel = async (Model, gqltype, { id, version }, advances, contex
|
|
|
1836
1871
|
// other operation passes its input, which attemptInputs copies. Either way, a retry checks the version
|
|
1837
1872
|
// the request sent.
|
|
1838
1873
|
const executeOperation = (Model, gqltype, controller, args, operation, actionField,
|
|
1839
|
-
session, context, inputType) => {
|
|
1874
|
+
session, context, inputType, actionName) => {
|
|
1840
1875
|
const limit = getMaxNestedOperations();
|
|
1841
1876
|
if (limit !== null && operation !== operations.DELETE) assertNestedOperations(gqltype, args, limit);
|
|
1842
1877
|
if (operation !== operations.DELETE) assertCollectionWrites(gqltype, args);
|
|
@@ -1848,26 +1883,36 @@ const executeOperation = (Model, gqltype, controller, args, operation, actionFie
|
|
|
1848
1883
|
session,
|
|
1849
1884
|
async (mySession) => {
|
|
1850
1885
|
const attemptArgs = nextInput();
|
|
1886
|
+
let record;
|
|
1851
1887
|
switch (operation) {
|
|
1852
1888
|
case operations.SAVE:
|
|
1853
|
-
|
|
1889
|
+
record = await onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
|
|
1890
|
+
break;
|
|
1854
1891
|
case operations.UPDATE: {
|
|
1855
1892
|
const precondition = versioned
|
|
1856
1893
|
? await preReadTopLevel(Model, gqltype, attemptArgs, true, context, mySession) : undefined;
|
|
1857
|
-
|
|
1894
|
+
record = await onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
|
|
1895
|
+
break;
|
|
1858
1896
|
}
|
|
1859
1897
|
case operations.DELETE: {
|
|
1860
|
-
if (!versioned)
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1898
|
+
if (!versioned) record = await onDelete(Model, controller, attemptArgs, mySession, context);
|
|
1899
|
+
else {
|
|
1900
|
+
const { expectedVersion, record: current } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
|
|
1901
|
+
mySession);
|
|
1902
|
+
record = await onDelete(Model, controller, attemptArgs.id, mySession, context, current, { gqltype, expectedVersion });
|
|
1903
|
+
}
|
|
1904
|
+
break;
|
|
1864
1905
|
}
|
|
1865
1906
|
case operations.STATE_CHANGED:
|
|
1866
|
-
|
|
1907
|
+
record = await onStateChanged(Model, gqltype, controller,
|
|
1867
1908
|
attemptArgs, mySession, actionField, context);
|
|
1909
|
+
break;
|
|
1868
1910
|
default:
|
|
1869
1911
|
return null;
|
|
1870
1912
|
}
|
|
1913
|
+
afterCommit.queue({ typeName: gqltype.name, controller, operation, record, args: attemptArgs,
|
|
1914
|
+
context, session: mySession, action: actionName });
|
|
1915
|
+
return record;
|
|
1871
1916
|
},
|
|
1872
1917
|
Model,
|
|
1873
1918
|
);
|
|
@@ -1932,19 +1977,22 @@ const executeItemFunction = async (gqltype, collectionField, parentKey, session,
|
|
|
1932
1977
|
}
|
|
1933
1978
|
}
|
|
1934
1979
|
|
|
1980
|
+
let record;
|
|
1935
1981
|
switch (operationType) {
|
|
1936
1982
|
case operations.SAVE:
|
|
1937
|
-
await onSaveObject(type.model, collectionGQLType, type.controller,
|
|
1983
|
+
record = await onSaveObject(type.model, collectionGQLType, type.controller,
|
|
1938
1984
|
args.input, session, linkToParent, context);
|
|
1939
1985
|
break;
|
|
1940
1986
|
case operations.UPDATE:
|
|
1941
|
-
await onUpdateSubject(type.model, collectionGQLType, type.controller,
|
|
1987
|
+
record = await onUpdateSubject(type.model, collectionGQLType, type.controller,
|
|
1942
1988
|
args.input, session, linkToParent, context, true, versioned ? { expectedVersion, observedVersion, record: child } : undefined);
|
|
1943
1989
|
break;
|
|
1944
1990
|
case operations.DELETE:
|
|
1945
|
-
await onDelete(type.model, type.controller, args.id, session, context, child,
|
|
1991
|
+
record = await onDelete(type.model, type.controller, args.id, session, context, child,
|
|
1946
1992
|
versioned ? { gqltype: collectionGQLType, expectedVersion } : null);
|
|
1947
1993
|
}
|
|
1994
|
+
afterCommit.queue({ typeName: collectionGQLType.name, controller: type.controller,
|
|
1995
|
+
operation: operationType, record, args: args.input, context, session });
|
|
1948
1996
|
}
|
|
1949
1997
|
};
|
|
1950
1998
|
|
|
@@ -2010,8 +2058,10 @@ const copyFilterGroup = (group) => {
|
|
|
2010
2058
|
if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
|
|
2011
2059
|
if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
|
|
2012
2060
|
if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
|
|
2061
|
+
for (const key of QUANTIFIED_LISTS) if (Array.isArray(group[key])) copy[key] = group[key].map(copyQuantifiedGroup);
|
|
2013
2062
|
return copy;
|
|
2014
2063
|
};
|
|
2064
|
+
const copyQuantifiedGroup = (group) => copyFilterGroup(group);
|
|
2015
2065
|
const copyFieldFilter = (filter) => {
|
|
2016
2066
|
const copy = copyFilterCondition(filter);
|
|
2017
2067
|
if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
|
|
@@ -2048,12 +2098,19 @@ const copyClientFilters = (args, operation) => {
|
|
|
2048
2098
|
// non-queryable field is not rejected; a virtual one still is.
|
|
2049
2099
|
const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
|
|
2050
2100
|
copyClientFilters(args, operation);
|
|
2101
|
+
assertSortSyntax(args.sort);
|
|
2051
2102
|
const joinedScopes = new Map();
|
|
2103
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2052
2104
|
const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
|
|
2053
2105
|
for (const {
|
|
2054
|
-
segments, group, member, operator,
|
|
2106
|
+
segments, group, member, operator, use, frames, quantifier,
|
|
2055
2107
|
} of entries) {
|
|
2056
|
-
const
|
|
2108
|
+
const filter = use === 'filter';
|
|
2109
|
+
const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
|
|
2110
|
+
const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
|
|
2111
|
+
const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
|
|
2112
|
+
const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
|
|
2113
|
+
&& !(filter && (frames.length || quantifier || (cut && !anchored)));
|
|
2057
2114
|
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2058
2115
|
const path = segments.slice(0, index + 1).join('.');
|
|
2059
2116
|
if (isVirtualField(field)) {
|
|
@@ -2063,8 +2120,11 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
|
|
|
2063
2120
|
if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
|
|
2064
2121
|
throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
|
|
2065
2122
|
}
|
|
2066
|
-
if (!joins) return;
|
|
2067
2123
|
const relation = field.extensions?.relation;
|
|
2124
|
+
if (use === 'sort' && operation !== 'aggregate' && relation && !relation.embedded && getListShape(field.type)) {
|
|
2125
|
+
throw new SimfinityError(`Sort path ${segments.join('.')} crosses the collection ${type.name}.${fieldName}; a record has no single value there`, 'INVALID_FILTER_PATH', 400);
|
|
2126
|
+
}
|
|
2127
|
+
if (!joins || (cut && index >= cut.index)) return;
|
|
2068
2128
|
if (!relation || relation.embedded) return;
|
|
2069
2129
|
const target = typesDict.types[unwrapListAndNonNull(field.type).name];
|
|
2070
2130
|
if (!getScope(target?.gqltype, 'find')) return;
|
|
@@ -2146,31 +2206,43 @@ const assertEmptinessForm = (gqltype, {
|
|
|
2146
2206
|
return true;
|
|
2147
2207
|
};
|
|
2148
2208
|
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2209
|
+
// Every runtime gate validates the received language, before rewriting or filling any markers.
|
|
2210
|
+
// Legacy adapters keep the detailed 2b diagnostics; quantified adapters use the shared validator's
|
|
2211
|
+
// frame-relative checks first. The separate operator inspection includes ignored beside-terms
|
|
2212
|
+
// carriers, which do not become predicates but must not use known undeclared capabilities.
|
|
2213
|
+
const queryFrameContext = (gqltype, args, operation) => ({
|
|
2214
|
+
gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
|
|
2215
|
+
operators: supportedOperators,
|
|
2216
|
+
J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
|
|
2217
|
+
scopeMarkers: 'pending',
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
const entryType = (gqltype, entry) => {
|
|
2221
|
+
let type = gqltype;
|
|
2222
|
+
const prefix = entry.segments.slice(0, entry.segments.length - entry.relative.length);
|
|
2223
|
+
if (prefix.length) walkQueryPath(gqltype, prefix, ({ field }) => { type = getNamedType(field.type); });
|
|
2224
|
+
return type;
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2159
2227
|
const assertQueryConditions = (gqltype, args, operation) => {
|
|
2160
|
-
|
|
2228
|
+
if (supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
|
|
2229
|
+
const conditions = [];
|
|
2161
2230
|
for (const entry of collectQueryOperators(args, operation)) {
|
|
2162
2231
|
const { operator, path } = entry;
|
|
2232
|
+
const type = entryType(gqltype, entry);
|
|
2163
2233
|
let shape = null;
|
|
2164
2234
|
if (isEmptinessOperator(operator)) {
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2235
|
+
// The validator owns quantified term forms; old top-frame forms retain their diagnostics.
|
|
2236
|
+
if (entry.frames.length || assertEmptinessForm(gqltype, entry)) {
|
|
2237
|
+
if (entry.relative.length) shape = describeEmptiness(type, entry.relative, {
|
|
2238
|
+
operator, value: entry.value, path: entry.relative.join('.'), referenceEmptiness: supportsReferenceEmptiness,
|
|
2168
2239
|
});
|
|
2169
2240
|
}
|
|
2170
2241
|
} else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
|
|
2171
|
-
|
|
2242
|
+
conditions.push({ entry, shape, type });
|
|
2172
2243
|
}
|
|
2173
|
-
|
|
2244
|
+
if (!supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
|
|
2245
|
+
return { conditions, gqltype, args, operation };
|
|
2174
2246
|
};
|
|
2175
2247
|
|
|
2176
2248
|
// Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
|
|
@@ -2187,9 +2259,8 @@ const assertQueryConditions = (gqltype, args, operation) => {
|
|
|
2187
2259
|
// the prefixed conditions are applied as trusted ones, which read stored data.
|
|
2188
2260
|
const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
|
|
2189
2261
|
const { gqltype } = target;
|
|
2190
|
-
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
|
|
2262
|
+
const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator, frames }) => !frames.length && isEmptinessOperator(operator));
|
|
2191
2263
|
if (!entries.length) return;
|
|
2192
|
-
assertStoredQueryPaths(gqltype, scopeArgs, 'find');
|
|
2193
2264
|
let throughList = false;
|
|
2194
2265
|
walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
|
|
2195
2266
|
// Rule 6, here or on the target's own endpoint, is the rejection thrown below.
|
|
@@ -2217,20 +2288,28 @@ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target
|
|
|
2217
2288
|
+ `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
|
|
2218
2289
|
};
|
|
2219
2290
|
|
|
2220
|
-
//
|
|
2221
|
-
//
|
|
2222
|
-
const assertJoinedScopePaths = (groups, prefix, target) => {
|
|
2291
|
+
// Declared collection frames preserve cardinality. A legacy adapter, or a prefix through an
|
|
2292
|
+
// embedded list, still cannot lift collection scopes; vector reference scopes remain forbidden.
|
|
2293
|
+
const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
|
|
2294
|
+
let embeddedPrefix = false;
|
|
2295
|
+
walkQueryPath(rootType, prefix.split('.'), ({ field }) => {
|
|
2296
|
+
embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
|
|
2297
|
+
});
|
|
2223
2298
|
const depth = prefix.split('.').length;
|
|
2224
|
-
for (const segments of
|
|
2299
|
+
for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
|
|
2225
2300
|
let inList = false;
|
|
2226
|
-
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field }) => {
|
|
2301
|
+
walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
|
|
2227
2302
|
const relation = field.extensions?.relation;
|
|
2228
2303
|
const list = !!getListShape(field.type);
|
|
2229
|
-
if (relation && !relation.embedded && (list ||
|
|
2304
|
+
if (relation && !relation.embedded && (inList || (list && (!supportsQuantifiers || embeddedPrefix)))) {
|
|
2230
2305
|
throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope filters through `
|
|
2231
2306
|
+ `${list ? 'the collection' : 'the relation'} ${type.name}.${fieldName}${list ? '' : ' inside a list'}`);
|
|
2232
2307
|
}
|
|
2233
|
-
|
|
2308
|
+
if (relation && !relation.embedded && quantifier && embeddedPrefix && index === segments.length - depth - 1) {
|
|
2309
|
+
throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope tests `
|
|
2310
|
+
+ `the quantified reference ${type.name}.${fieldName} under an embedded-list prefix`);
|
|
2311
|
+
}
|
|
2312
|
+
inList = relation && !relation.embedded ? false : inList || list;
|
|
2234
2313
|
});
|
|
2235
2314
|
}
|
|
2236
2315
|
};
|
|
@@ -2244,7 +2323,7 @@ const appendAndGroups = (owner, groups) => {
|
|
|
2244
2323
|
|
|
2245
2324
|
// The deepest AND/OR group nesting the backends accept; groups listed in args.AND/OR are at depth 0.
|
|
2246
2325
|
const MAX_FILTER_GROUP_DEPTH = 5;
|
|
2247
|
-
const nestedGroups = (group) => [...
|
|
2326
|
+
const nestedGroups = (group) => ['AND', 'OR', ...QUANTIFIED_LISTS].flatMap((key) => [].concat(group[key] ?? []));
|
|
2248
2327
|
const groupDepth = (group) => Math.max(0, ...nestedGroups(group)
|
|
2249
2328
|
.filter((item) => item && typeof item === 'object').map((item) => groupDepth(item) + 1));
|
|
2250
2329
|
|
|
@@ -2271,10 +2350,13 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2271
2350
|
}] of joinedScopes) {
|
|
2272
2351
|
const scopeArgs = {};
|
|
2273
2352
|
await executeScope({ type: target, args: scopeArgs, operation: 'find', context: params.context });
|
|
2274
|
-
const
|
|
2353
|
+
const kept = scopeFilterArgs(scopeArgs);
|
|
2354
|
+
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2355
|
+
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2356
|
+
const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
|
|
2275
2357
|
if (!groups.length) continue;
|
|
2276
2358
|
if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
|
|
2277
|
-
assertJoinedScopePaths(groups, prefix, target);
|
|
2359
|
+
assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
|
|
2278
2360
|
assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
|
|
2279
2361
|
queryGroups ??= collectFilterGroups(params.args);
|
|
2280
2362
|
const addedDepth = 1 + Math.max(...groups.map(groupDepth));
|
|
@@ -2284,60 +2366,93 @@ const applyJoinedScopes = async (joinedScopes, params) => {
|
|
|
2284
2366
|
}
|
|
2285
2367
|
};
|
|
2286
2368
|
|
|
2287
|
-
// Client scope
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
// issued is still there and that no other one is, gates the final arguments, then fills each marker
|
|
2292
|
-
// with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
|
|
2293
|
-
// { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
|
|
2294
|
-
// those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
|
|
2295
|
-
// Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
|
|
2296
|
-
// anywhere else, inside a value for one, is never read.
|
|
2369
|
+
// Client scope provenance is carried by terms, conditions and explicit frames, never by plain
|
|
2370
|
+
// logical groups or values. Only reads of registered scoped targets are marked. Middleware sees
|
|
2371
|
+
// ordinal IDs; after detach and final validation, fill adds relative target-scope arguments. The
|
|
2372
|
+
// planner/compiler rewrite, not the runtime, splits those keys across implicit collection frames.
|
|
2297
2373
|
const SCOPE_MARKER = '__scope';
|
|
2298
2374
|
const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
|
|
2299
2375
|
+ 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
|
|
2300
2376
|
const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
|
|
2301
2377
|
const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
|
|
2302
2378
|
|
|
2303
|
-
//
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2379
|
+
// Relation metadata is position-relative when used for fill and absolute for eligibility/recording.
|
|
2380
|
+
const pathRelations = (gqltype, segments) => {
|
|
2381
|
+
const found = [];
|
|
2382
|
+
let insideList = false;
|
|
2383
|
+
walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
|
|
2384
|
+
const relation = field.extensions?.relation;
|
|
2385
|
+
const list = !!getListShape(field.type);
|
|
2386
|
+
if (relation && !relation.embedded) found.push({
|
|
2387
|
+
path: segments.slice(0, index + 1).join('.'), index,
|
|
2388
|
+
typeName: getNamedType(field.type).name, collection: list, insideList, owner: type.name, fieldName,
|
|
2389
|
+
});
|
|
2390
|
+
// A collection enters an element frame; only embedded lists make vector reference joins.
|
|
2391
|
+
if (relation && !relation.embedded && list) insideList = false;
|
|
2392
|
+
else if (!relation || relation.embedded) insideList ||= list;
|
|
2393
|
+
});
|
|
2394
|
+
return found;
|
|
2395
|
+
};
|
|
2396
|
+
|
|
2397
|
+
// The walker retains scalar-filter terms for capability/auth inspection, but no compiler consumes
|
|
2398
|
+
// them. Such positions cannot establish client provenance, including when aliased with a predicate.
|
|
2399
|
+
const ignoredScalarTerm = (gqltype, entry) => {
|
|
2400
|
+
if (entry.form !== 'term' || entry.frames.length || typeof entry.field !== 'string') return false;
|
|
2401
|
+
const segments = entry.field.split('.');
|
|
2402
|
+
let scalar = false;
|
|
2403
|
+
walkQueryPath(gqltype, segments, ({ field, index }) => {
|
|
2404
|
+
if (index === segments.length - 1) scalar = !isObjectType(getNamedType(field.type));
|
|
2405
|
+
});
|
|
2406
|
+
return scalar;
|
|
2407
|
+
};
|
|
2408
|
+
|
|
2409
|
+
const ordinaryFrameCarrier = (gqltype, entry, J) => supportsQuantifiers && entry.construct
|
|
2410
|
+
&& !ignoredScalarTerm(gqltype, entry)
|
|
2411
|
+
&& entry.form !== 'beside-terms' && (entry.frames.length || pathRelations(gqltype, entry.segments)
|
|
2412
|
+
.some(({ path, collection }) => collection && !J.has(path)));
|
|
2413
|
+
|
|
2414
|
+
// Eligibility is per carrier: ordinary legacy reference-only/all-J predicates keep joined scopes.
|
|
2415
|
+
// Enclosing explicit frames inherit provenance from marked descendants, independently of unit counts.
|
|
2416
|
+
// A view continues issuance on the same map, but checks its own compiled-unit budget (as in 3.12.0).
|
|
2417
|
+
const markClientConstructs = (checked, { aggregation, issued = new Map() } = {}) => {
|
|
2418
|
+
const { gqltype, args, operation, conditions } = checked;
|
|
2419
|
+
const clientJ = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, { gqltype }) : new Set();
|
|
2420
|
+
if (!supportsReferenceEmptiness) return { issued: null, clientJ };
|
|
2421
|
+
const mark = (construct, origin) => {
|
|
2422
|
+
if (!construct || hasScopeMarker(construct)) return;
|
|
2423
|
+
const id = issued.size;
|
|
2321
2424
|
construct[SCOPE_MARKER] = { id };
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2425
|
+
issued.set(id, origin);
|
|
2426
|
+
};
|
|
2427
|
+
for (const { entry, shape } of conditions) {
|
|
2428
|
+
const { construct } = entry;
|
|
2429
|
+
if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
|
|
2430
|
+
const targets = pathRelations(gqltype, entry.segments).map(({ typeName }) => typeName);
|
|
2431
|
+
if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
|
|
2432
|
+
if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
|
|
2433
|
+
const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
|
|
2434
|
+
mark(construct, origin);
|
|
2435
|
+
const prefix = [];
|
|
2436
|
+
for (const frame of entry.frames) {
|
|
2437
|
+
prefix.push(...frame.path.split('.'));
|
|
2438
|
+
mark(frame.construct, { operator: frame.type, path: prefix.join('.') });
|
|
2439
|
+
}
|
|
2325
2440
|
}
|
|
2441
|
+
const { count } = frameUnits(args, queryFrameContext(gqltype, args, operation));
|
|
2326
2442
|
const limit = getQueryMaxReferenceFrames();
|
|
2327
|
-
if (
|
|
2328
|
-
throw new SimfinityError(`
|
|
2329
|
-
+ 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
|
|
2330
|
-
+ `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
|
|
2443
|
+
if (count > limit) {
|
|
2444
|
+
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);
|
|
2331
2445
|
}
|
|
2332
|
-
return
|
|
2446
|
+
return { issued, clientJ };
|
|
2333
2447
|
};
|
|
2334
2448
|
|
|
2335
|
-
//
|
|
2336
|
-
//
|
|
2337
|
-
|
|
2338
|
-
const
|
|
2449
|
+
// Every issued ID must survive on a valid carrier, with no forged or prefilled marker. Marked
|
|
2450
|
+
// descendants may only live inside marked client frames; top-frame collection prefixes keep J.
|
|
2451
|
+
const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
|
|
2452
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2339
2453
|
const found = new Set();
|
|
2340
|
-
for (const
|
|
2454
|
+
for (const entry of collectQueryOperators(args, operation)) {
|
|
2455
|
+
const { construct, operator, path } = entry;
|
|
2341
2456
|
if (!hasScopeMarker(construct)) continue;
|
|
2342
2457
|
const marker = construct[SCOPE_MARKER];
|
|
2343
2458
|
const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
|
|
@@ -2348,6 +2463,17 @@ const assertClientMarkers = (args, operation, issued) => {
|
|
|
2348
2463
|
if (!origin) {
|
|
2349
2464
|
throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
|
|
2350
2465
|
}
|
|
2466
|
+
if (ignoredScalarTerm(gqltype, entry)) {
|
|
2467
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is in ignored scalar filter terms`);
|
|
2468
|
+
}
|
|
2469
|
+
if (entry.frames.some((frame) => !hasScopeMarker(frame.construct))) {
|
|
2470
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
|
|
2471
|
+
}
|
|
2472
|
+
if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
|
|
2473
|
+
for (const relation of pathRelations(gqltype, entry.segments)) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
|
|
2474
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2351
2477
|
found.add(marker.id);
|
|
2352
2478
|
}
|
|
2353
2479
|
for (const [id, origin] of issued) {
|
|
@@ -2368,6 +2494,15 @@ const relationOnPath = (gqltype, segments) => {
|
|
|
2368
2494
|
return found;
|
|
2369
2495
|
};
|
|
2370
2496
|
|
|
2497
|
+
const scopeFilterArgs = (scopeArgs) => {
|
|
2498
|
+
const kept = {};
|
|
2499
|
+
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2500
|
+
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2501
|
+
else if (value != null) assertScopeControlArg(name, value);
|
|
2502
|
+
}
|
|
2503
|
+
return kept;
|
|
2504
|
+
};
|
|
2505
|
+
|
|
2371
2506
|
// The find scope arguments of a type that a client's constructs read through, once per call and type
|
|
2372
2507
|
// (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
|
|
2373
2508
|
// joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
|
|
@@ -2380,11 +2515,7 @@ const clientScopeArgs = async (typeName, context, cache) => {
|
|
|
2380
2515
|
if (getScope(target?.gqltype, 'find')) {
|
|
2381
2516
|
const scopeArgs = {};
|
|
2382
2517
|
await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
|
|
2383
|
-
const kept =
|
|
2384
|
-
for (const [name, value] of Object.entries(scopeArgs)) {
|
|
2385
|
-
if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
|
|
2386
|
-
else if (value != null) assertScopeControlArg(name, value);
|
|
2387
|
-
}
|
|
2518
|
+
const kept = scopeFilterArgs(scopeArgs);
|
|
2388
2519
|
assertStoredQueryPaths(target.gqltype, kept, 'find');
|
|
2389
2520
|
assertQueryConditions(target.gqltype, kept, 'find');
|
|
2390
2521
|
if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
|
|
@@ -2393,19 +2524,15 @@ const clientScopeArgs = async (typeName, context, cache) => {
|
|
|
2393
2524
|
return result;
|
|
2394
2525
|
};
|
|
2395
2526
|
|
|
2396
|
-
//
|
|
2397
|
-
//
|
|
2398
|
-
//
|
|
2399
|
-
//
|
|
2400
|
-
// keys of every position when middleware placed the object at several;
|
|
2401
|
-
// - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
|
|
2402
|
-
// - another operator loses it too, unless its path still crosses or ends at a reference or a
|
|
2403
|
-
// collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
|
|
2404
|
-
// An object that reads references at one position and none at another fails the same way, since one
|
|
2405
|
-
// marker cannot serve both. Every check comes before any target scope runs.
|
|
2527
|
+
// Preflight every final position before running target scopes: union relative keys, reject target
|
|
2528
|
+
// collisions and mixed reads, and retain the compiler's rejection of marked legacy predicates.
|
|
2529
|
+
// J collection keys are null; anchor references remain legacy joins. Keyless carriers lose their
|
|
2530
|
+
// marker. One per-call type cache serves all remaining keys, separately from joined-scope execution.
|
|
2406
2531
|
const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
2532
|
+
const { conditions, args, operation } = checked;
|
|
2533
|
+
const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
|
|
2407
2534
|
const marked = new Map();
|
|
2408
|
-
for (const item of
|
|
2535
|
+
for (const item of conditions) {
|
|
2409
2536
|
const { construct } = item.entry;
|
|
2410
2537
|
if (!hasScopeMarker(construct)) continue;
|
|
2411
2538
|
if (!marked.has(construct)) marked.set(construct, []);
|
|
@@ -2417,18 +2544,29 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2417
2544
|
const origin = issued.get(id);
|
|
2418
2545
|
const targets = {};
|
|
2419
2546
|
const unread = [];
|
|
2420
|
-
|
|
2421
|
-
|
|
2547
|
+
const vectors = [];
|
|
2548
|
+
for (const { entry, shape, type } of positions) {
|
|
2549
|
+
const relations = pathRelations(type, entry.relative);
|
|
2550
|
+
if (!shape && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
|
|
2422
2551
|
const relation = relationOnPath(gqltype, entry.segments);
|
|
2423
|
-
if (relation) {
|
|
2424
|
-
|
|
2425
|
-
|
|
2552
|
+
if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
|
|
2553
|
+
+ `${entry.path}, which reads through ${relation} without its scope`);
|
|
2554
|
+
}
|
|
2555
|
+
const rootShorthand = !entry.frames.length && entry.form !== 'quantified' && !shape;
|
|
2556
|
+
const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
|
|
2557
|
+
const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
|
|
2558
|
+
const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
|
|
2559
|
+
.filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
|
|
2560
|
+
.map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
|
|
2561
|
+
for (const [key, target] of Object.entries(keys)) {
|
|
2562
|
+
if (Object.hasOwn(targets, key) && targets[key] !== target) {
|
|
2563
|
+
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} uses ${key} for different scope targets`);
|
|
2426
2564
|
}
|
|
2427
|
-
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2565
|
+
targets[key] = target;
|
|
2566
|
+
}
|
|
2567
|
+
if (!Object.keys(keys).length) unread.push(entry.path);
|
|
2568
|
+
for (const relation of relations) if (!relation.collection && relation.insideList && Object.hasOwn(keys, relation.path)) {
|
|
2569
|
+
vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
|
|
2432
2570
|
}
|
|
2433
2571
|
}
|
|
2434
2572
|
const paths = Object.keys(targets);
|
|
@@ -2436,16 +2574,16 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2436
2574
|
throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
|
|
2437
2575
|
+ `references or collections and where it reads none (${unread.join(', ')})`);
|
|
2438
2576
|
}
|
|
2439
|
-
fills.push({ construct, id, targets: paths.length ? targets : null });
|
|
2577
|
+
fills.push({ construct, id, targets: paths.length ? targets : null, vectors });
|
|
2440
2578
|
}
|
|
2441
2579
|
const cache = new Map();
|
|
2442
|
-
for (const { construct, id, targets } of fills) {
|
|
2443
|
-
if (!targets) {
|
|
2444
|
-
delete construct[SCOPE_MARKER];
|
|
2445
|
-
continue;
|
|
2446
|
-
}
|
|
2580
|
+
for (const { construct, id, targets, vectors } of fills) {
|
|
2581
|
+
if (!targets) { delete construct[SCOPE_MARKER]; continue; }
|
|
2447
2582
|
const scopes = {};
|
|
2448
|
-
for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
|
|
2583
|
+
for (const [path, typeName] of Object.entries(targets)) scopes[path] = typeName === null ? null : await clientScopeArgs(typeName, context, cache);
|
|
2584
|
+
for (const relation of vectors) if (scopes[relation.path] !== null) {
|
|
2585
|
+
throw forbiddenPath(`Query path ${relation.absolute} enters ${relation.typeName}, whose find scope restricts the relation ${relation.owner}.${relation.fieldName} inside a list`);
|
|
2586
|
+
}
|
|
2449
2587
|
construct[SCOPE_MARKER] = { id, scopes };
|
|
2450
2588
|
}
|
|
2451
2589
|
};
|
|
@@ -2455,8 +2593,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
|
|
|
2455
2593
|
// the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
|
|
2456
2594
|
// Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
|
|
2457
2595
|
// the adapter after as many promise jobs as before.
|
|
2458
|
-
const settleClientArgs = (gqltype, params, operation, issued) => {
|
|
2459
|
-
if (issued) assertClientMarkers(params.args, operation, issued);
|
|
2596
|
+
const settleClientArgs = (gqltype, params, operation, { issued, clientJ }) => {
|
|
2597
|
+
if (issued) assertClientMarkers(gqltype, params.args, operation, issued, clientJ);
|
|
2460
2598
|
assertStoredQueryPaths(gqltype, params.args, operation);
|
|
2461
2599
|
const checked = assertQueryConditions(gqltype, params.args, operation);
|
|
2462
2600
|
return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
|
|
@@ -3044,7 +3182,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
3044
3182
|
await executeMiddleware(params);
|
|
3045
3183
|
return executeOperation(type.model, type.gqltype, type.controller,
|
|
3046
3184
|
args.input, operations.STATE_CHANGED, actionField,
|
|
3047
|
-
null, context, type.inputType);
|
|
3185
|
+
null, context, type.inputType, actionName);
|
|
3048
3186
|
},
|
|
3049
3187
|
};
|
|
3050
3188
|
}
|
|
@@ -3073,7 +3211,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
|
|
|
3073
3211
|
// without one still reaches the adapter after as many promise jobs as before it was shared.
|
|
3074
3212
|
const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
|
|
3075
3213
|
const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
|
|
3076
|
-
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
|
|
3214
|
+
const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation), { aggregation: args.aggregation });
|
|
3077
3215
|
const params = entry === undefined
|
|
3078
3216
|
? { type, args, operation, context }
|
|
3079
3217
|
: { type, args, operation, entry, context };
|
|
@@ -3122,13 +3260,13 @@ const splitViewArgs = (paramNames, args) => {
|
|
|
3122
3260
|
|
|
3123
3261
|
const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
|
|
3124
3262
|
|
|
3125
|
-
// The groups a view's `where` returned are plain objects whose
|
|
3263
|
+
// The groups a view's `where` returned are plain objects whose condition and Boolean/quantified lists, when set,
|
|
3126
3264
|
// are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
|
|
3127
3265
|
// skips anything else silently, so this comes before their paths are read. Only the GraphQL input
|
|
3128
|
-
// fields are structural keys; condition values stay opaque.
|
|
3129
|
-
// marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
3130
|
-
const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
3131
|
-
const groupFields = QLFilterGroup.getFields();
|
|
3266
|
+
// fields are structural keys; condition values stay opaque. Conditions and quantified groups never
|
|
3267
|
+
// carry a client scope marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
|
|
3268
|
+
const assertViewGroupShapes = (view, groups, ancestors = new Set(), quantified = false) => {
|
|
3269
|
+
const groupFields = (quantified ? QLQuantifiedGroup : QLFilterGroup).getFields();
|
|
3132
3270
|
const conditionFields = QLFilterCondition.getFields();
|
|
3133
3271
|
for (const group of groups) {
|
|
3134
3272
|
if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
|
|
@@ -3136,7 +3274,11 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
|
3136
3274
|
for (const key of Object.keys(group)) {
|
|
3137
3275
|
if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
|
|
3138
3276
|
}
|
|
3277
|
+
if (quantified && (typeof group.path !== 'string' || group.path.length === 0)) {
|
|
3278
|
+
throw invalidWhere(view, 'returned a quantified group without a non-empty path');
|
|
3279
|
+
}
|
|
3139
3280
|
for (const key of Object.keys(groupFields)) {
|
|
3281
|
+
if (key === 'path') continue;
|
|
3140
3282
|
if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
|
|
3141
3283
|
}
|
|
3142
3284
|
for (const condition of group.conditions ?? []) {
|
|
@@ -3152,6 +3294,7 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
|
|
|
3152
3294
|
}
|
|
3153
3295
|
ancestors.add(group);
|
|
3154
3296
|
assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
|
|
3297
|
+
for (const key of QUANTIFIED_LISTS) assertViewGroupShapes(view, group[key] ?? [], ancestors, true);
|
|
3155
3298
|
ancestors.delete(group);
|
|
3156
3299
|
}
|
|
3157
3300
|
};
|
|
@@ -3774,6 +3917,7 @@ const readSchemaOptions = (options) => {
|
|
|
3774
3917
|
};
|
|
3775
3918
|
|
|
3776
3919
|
const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
|
|
3920
|
+
Object.values(typesDict.types).forEach(({ controller }) => afterCommit.validate(controller));
|
|
3777
3921
|
// Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
|
|
3778
3922
|
const { includedCustomQueries } = readSchemaOptions(options);
|
|
3779
3923
|
assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
|
|
@@ -3861,6 +4005,15 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
|
|
|
3861
4005
|
}
|
|
3862
4006
|
|
|
3863
4007
|
const schema = constructSchema(query, mutation, reserveVersionNames);
|
|
4008
|
+
captureSchemaRegistrations(schema, Object.values(typesDict.types).map((entry) => ({
|
|
4009
|
+
gqltype: entry.gqltype, endpoint: entry.endpoint,
|
|
4010
|
+
// Only associate roots generated for this schema. Excluded endpoint names can be custom queries.
|
|
4011
|
+
simpleEntityEndpointName: shouldNotBeIncludedInSchema(includedQueryTypes, entry.gqltype)
|
|
4012
|
+
? null : entry.simpleEntityEndpointName,
|
|
4013
|
+
inputType: entry.inputType, updateInputType: typesDictForUpdate.types[entry.gqltype.name]?.inputType,
|
|
4014
|
+
stateMachine: entry.stateMachine,
|
|
4015
|
+
})), (type, field) => field.extensions?.queryable !== false
|
|
4016
|
+
&& (field.extensions?.queryable === true || !hasApplicationResolver(type, field.name)));
|
|
3864
4017
|
// Reserve reachable types whose relation fields still have no resolver, such as an unregistered
|
|
3865
4018
|
// custom operation result, so another runtime cannot later generate resolvers this schema executes.
|
|
3866
4019
|
schemaTypes.forEach((type) => {
|
|
@@ -4216,6 +4369,7 @@ function formatArgs(argsArray) {
|
|
|
4216
4369
|
return {
|
|
4217
4370
|
configureQueryLimits,
|
|
4218
4371
|
configureMutationLimits,
|
|
4372
|
+
configureAfterCommit: afterCommit.configure,
|
|
4219
4373
|
connect,
|
|
4220
4374
|
addNoEndpointType,
|
|
4221
4375
|
createSchema,
|