@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/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, collectQueryPaths, walkQueryPath,
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, emptinessScopePaths, emptinessScopeTargets, isEmptinessOperator,
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) => onSaveObject(
1756
- type.model, type.gqltype, type.controller, cloneInput(snapshot, type.inputType), transaction, null, context,
1757
- ), type.model);
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
- return onSaveObject(Model, gqltype, controller, attemptArgs, mySession, null, context);
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
- return onUpdateSubject(Model, gqltype, controller, attemptArgs, mySession, null, context, false, precondition);
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) return onDelete(Model, controller, attemptArgs, mySession, context);
1861
- const { expectedVersion, record } = await preReadTopLevel(Model, gqltype, attemptArgs, false, context,
1862
- mySession);
1863
- return onDelete(Model, controller, attemptArgs.id, mySession, context, record, { gqltype, expectedVersion });
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
- return onStateChanged(Model, gqltype, controller,
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 joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator));
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
- // Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
2150
- // UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
2151
- // and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
2152
- // whatever the adapter declares, rule 6 only when it does not declare reference emptiness. Runs on
2153
- // the client's arguments before middleware and on the final arguments before the adapter call,
2154
- // which covers what middleware, scopes and joined scopes added, so that an emptiness condition gets
2155
- // the code that a direct createQueryPlan call gives, whoever wrote it and whatever the caller may
2156
- // read. Operators that QLOperator does not list are left to the adapter, which reports them as before.
2157
- // Returns every condition as `{ entry, shape }`: its collectQueryOperators entry, and the shape of an
2158
- // emptiness condition that the backends read as one, or null.
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
- const checked = [];
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
- if (assertEmptinessForm(gqltype, entry)) {
2166
- shape = describeEmptiness(gqltype, entry.segments, {
2167
- operator, value: entry.value, path, referenceEmptiness: supportsReferenceEmptiness,
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
- checked.push({ entry, shape });
2242
+ conditions.push({ entry, shape, type });
2172
2243
  }
2173
- return checked;
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
- // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
2221
- // reach a non-embedded relation inside an embedded list, which is joined once per list item.
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 collectQueryPaths({ AND: groups })) {
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 || inList)) {
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
- inList ||= list;
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) => [...[].concat(group.AND ?? []), ...[].concat(group.OR ?? [])];
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 groups = prefixScopeArgs(scopeArgs, prefix, target.gqltype.name);
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 markers (#108 part 2b). When the adapter compiles reference emptiness, each IS_EMPTY or
2288
- // IS_NOT_EMPTY term or group condition that a client sends and that crosses or ends at a reference or a
2289
- // collection, or reads one as a member, gets `__scope: { id }` on this call's copy, before middleware.
2290
- // After middleware, the query's own scope and joined scopes, the runtime checks that every marker it
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
- // Marks the client's constructs that read references or collections, from the gate's conditions on the
2304
- // client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
2305
- // scoped joins and frames in these conditions, so every 2a code comes first. Returns the issued
2306
- // markers, as id -> the client's operator and path, or null when the adapter does not compile
2307
- // reference emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
2308
- // identical markers and sibling collection reads keep one batch key. With `issued`, the markers of
2309
- // the call so far, the ids continue on that map, so that a view's `where`, marked after the client's
2310
- // arguments, shares them; the limit still applies to each set of conditions on its own.
2311
- const markClientConstructs = (checked, issued) => {
2312
- if (!supportsReferenceEmptiness) return null;
2313
- const markers = issued ?? new Map();
2314
- const pairs = new Set();
2315
- for (const { entry, shape } of checked) {
2316
- const { construct } = entry;
2317
- if (!shape || !construct || hasScopeMarker(construct)) continue;
2318
- const { joins, frames } = emptinessScopePaths(shape, entry.segments);
2319
- if (!joins.length && !frames.length) continue;
2320
- const id = markers.size;
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
- markers.set(id, { operator: entry.operator, path: entry.path });
2323
- for (const path of joins) pairs.add(`join ${path}`);
2324
- for (const path of frames) pairs.add(`frame ${path}`);
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 (pairs.size > limit) {
2328
- throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
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 markers;
2446
+ return { issued, clientJ };
2333
2447
  };
2334
2448
 
2335
- // The detach check: every marker issued in this call sits, well formed (an object whose only own key
2336
- // is its id), on a term or group condition of the final arguments, possibly on several when middleware
2337
- // copied or duplicated the construct; no other marker sits on one.
2338
- const assertClientMarkers = (args, operation, issued) => {
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 { construct, operator, path } of collectQueryOperators(args, operation)) {
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
- // The fill, on the final arguments the gate passed (`checked`). Each marked object is read at each of
2397
- // its final positions, with its final operator and path:
2398
- // - an emptiness condition that crosses, ends at or has as members references or collections gets a
2399
- // fresh `__scope: { id, scopes }`, one key per scoped join and frame path, with the union of the
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 checked) {
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
- for (const { entry, shape } of positions) {
2421
- if (!entry.emptiness) {
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
- throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
2425
- + `${entry.path}, which reads through ${relation} without its scope`);
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
- unread.push(entry.path);
2428
- } else {
2429
- const keys = shape ? emptinessScopeTargets(shape, entry.segments) : {};
2430
- if (Object.keys(keys).length) Object.assign(targets, keys);
2431
- else unread.push(entry.path);
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 `conditions`, `AND` and `OR`, when set,
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. A condition never carries a client scope
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,