@simtlix/simfinity-core 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.js CHANGED
@@ -13,14 +13,16 @@ import SimfinityError from './errors/simfinity.error.js';
13
13
  import InternalServerError from './errors/internal-server.error.js';
14
14
  import { ForbiddenError } from './auth/errors.js';
15
15
  import QLOperator from './const/QLOperator.js';
16
+ import QLQuantifier, { QUANTIFIED_LISTS } from './const/QLQuantifier.js';
17
+ import { aggregateJoinPrefixes, assertSortSyntax, frameUnits, validateQuantifiedGroups } from './query-frames.js';
16
18
  import QLValue from './const/QLValue.js';
17
19
  import QLSort from './const/QLSort.js';
18
20
  import {
19
- collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
21
+ collectQueryOperators, collectQueryPathEntries, walkQueryPath,
20
22
  } from './query-plan.js';
21
23
  import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
22
24
  import {
23
- assertEmptinessPath, describeEmptiness, emptinessScopePaths, emptinessScopeTargets, isEmptinessOperator,
25
+ assertEmptinessPath, describeEmptiness, emptinessScopeTargets, isEmptinessOperator,
24
26
  } from './query-emptiness.js';
25
27
  import { relationFieldOwners } from './relation-owners.js';
26
28
  import { isThenable } from './auth/thenable.js';
@@ -181,7 +183,7 @@ const assertScopeControlArg = (name, value) => {
181
183
  };
182
184
 
183
185
  // Moves a joined type's scope group below the relation path that entered it.
184
- const prefixScopeGroup = (group, prefix) => {
186
+ const prefixScopeGroup = (group, prefix, copyQuantifiedGroup) => {
185
187
  if (!group || typeof group !== 'object' || Array.isArray(group)) {
186
188
  throw new SimfinityError('Expected a scope filter group object', 'INVALID_FILTER_VALUE', 400);
187
189
  }
@@ -193,12 +195,15 @@ const prefixScopeGroup = (group, prefix) => {
193
195
  value: condition?.value,
194
196
  }));
195
197
  }
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));
198
+ if (group.AND != null) prefixed.AND = [].concat(group.AND).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
199
+ if (group.OR != null) prefixed.OR = [].concat(group.OR).map((item) => prefixScopeGroup(item, prefix, copyQuantifiedGroup));
200
+ for (const key of QUANTIFIED_LISTS) if (group[key] != null) {
201
+ prefixed[key] = group[key].map((item) => ({ ...copyQuantifiedGroup(item), path: `${prefix}.${item.path}` }));
202
+ }
198
203
  return prefixed;
199
204
  };
200
205
 
201
- const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
206
+ const prefixScopeArgs = (scopeArgs, prefix, typeName, copyQuantifiedGroup) => {
202
207
  const conditions = [];
203
208
  const groups = [];
204
209
  for (const [name, value] of Object.entries(scopeArgs)) {
@@ -207,10 +212,12 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
207
212
  assertScopeControlArg(name, value);
208
213
  continue;
209
214
  }
210
- if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix)));
215
+ if (name === 'AND') groups.push(...[].concat(value).map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)));
211
216
  else if (name === 'OR') {
212
217
  const branches = [].concat(value);
213
- if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix)) });
218
+ if (branches.length) groups.push({ OR: branches.map((group) => prefixScopeGroup(group, prefix, copyQuantifiedGroup)) });
219
+ } else if (value.quantifier != null) {
220
+ groups.push({ [value.quantifier]: [{ path: `${prefix}.${name}`, conditions: (value.terms ?? []).map(({ path, ...term }) => ({ field: path, ...term })) }] });
214
221
  } else if (Object.hasOwn(value, 'terms')) {
215
222
  if (!Array.isArray(value.terms) || !value.terms.length) {
216
223
  throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
@@ -545,7 +552,7 @@ export const createRuntime = (adapter) => {
545
552
  // IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
546
553
  // conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
547
554
  const {
548
- operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
555
+ operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness, quantifiers: supportsQuantifiers,
549
556
  } = resolveQueryFeatures(adapter.queryFeatures);
550
557
  const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
551
558
  // The write features the adapter implements, read once: with optimisticConcurrency, schemas may
@@ -697,6 +704,7 @@ const QLTypeFilterExpression = new GraphQLInputObjectType({
697
704
  name: 'QLTypeFilterExpression',
698
705
  fields: () => ({
699
706
  terms: { type: new GraphQLList(QLTypeFilter) },
707
+ quantifier: { type: QLQuantifier },
700
708
  }),
701
709
  });
702
710
 
@@ -710,9 +718,25 @@ const QLFilterCondition = new GraphQLInputObjectType({
710
718
  }),
711
719
  });
712
720
 
721
+ const QLQuantifiedGroup = new GraphQLInputObjectType({
722
+ name: 'QLQuantifiedGroup',
723
+ fields: () => ({
724
+ path: { type: new GraphQLNonNull(GraphQLString) },
725
+ AND: { type: new GraphQLList(QLFilterGroup) },
726
+ OR: { type: new GraphQLList(QLFilterGroup) },
727
+ ANY: { type: new GraphQLList(QLQuantifiedGroup) },
728
+ ALL: { type: new GraphQLList(QLQuantifiedGroup) },
729
+ NONE: { type: new GraphQLList(QLQuantifiedGroup) },
730
+ conditions: { type: new GraphQLList(QLFilterCondition) },
731
+ }),
732
+ });
733
+
713
734
  const QLFilterGroup = new GraphQLInputObjectType({
714
735
  name: 'QLFilterGroup',
715
736
  fields: () => ({
737
+ ANY: { type: new GraphQLList(QLQuantifiedGroup) },
738
+ ALL: { type: new GraphQLList(QLQuantifiedGroup) },
739
+ NONE: { type: new GraphQLList(QLQuantifiedGroup) },
716
740
  AND: { type: new GraphQLList(QLFilterGroup) },
717
741
  OR: { type: new GraphQLList(QLFilterGroup) },
718
742
  conditions: { type: new GraphQLList(QLFilterCondition) },
@@ -2010,8 +2034,10 @@ const copyFilterGroup = (group) => {
2010
2034
  if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
2011
2035
  if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
2012
2036
  if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
2037
+ for (const key of QUANTIFIED_LISTS) if (Array.isArray(group[key])) copy[key] = group[key].map(copyQuantifiedGroup);
2013
2038
  return copy;
2014
2039
  };
2040
+ const copyQuantifiedGroup = (group) => copyFilterGroup(group);
2015
2041
  const copyFieldFilter = (filter) => {
2016
2042
  const copy = copyFilterCondition(filter);
2017
2043
  if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
@@ -2048,12 +2074,19 @@ const copyClientFilters = (args, operation) => {
2048
2074
  // non-queryable field is not rejected; a virtual one still is.
2049
2075
  const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
2050
2076
  copyClientFilters(args, operation);
2077
+ assertSortSyntax(args.sort);
2051
2078
  const joinedScopes = new Map();
2079
+ const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
2052
2080
  const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
2053
2081
  for (const {
2054
- segments, group, member, operator,
2082
+ segments, group, member, operator, use, frames, quantifier,
2055
2083
  } of entries) {
2056
- const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator));
2084
+ const filter = use === 'filter';
2085
+ const relations = filter && supportsQuantifiers ? pathRelations(gqltype, segments) : [];
2086
+ const cut = relations.find((relation) => relation.collection && !J.has(relation.path));
2087
+ const anchored = cut && relations.some((relation) => relation.collection && J.has(relation.path));
2088
+ const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator))
2089
+ && !(filter && (frames.length || quantifier || (cut && !anchored)));
2057
2090
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
2058
2091
  const path = segments.slice(0, index + 1).join('.');
2059
2092
  if (isVirtualField(field)) {
@@ -2063,8 +2096,11 @@ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true }
2063
2096
  if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
2064
2097
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
2065
2098
  }
2066
- if (!joins) return;
2067
2099
  const relation = field.extensions?.relation;
2100
+ if (use === 'sort' && operation !== 'aggregate' && relation && !relation.embedded && getListShape(field.type)) {
2101
+ throw new SimfinityError(`Sort path ${segments.join('.')} crosses the collection ${type.name}.${fieldName}; a record has no single value there`, 'INVALID_FILTER_PATH', 400);
2102
+ }
2103
+ if (!joins || (cut && index >= cut.index)) return;
2068
2104
  if (!relation || relation.embedded) return;
2069
2105
  const target = typesDict.types[unwrapListAndNonNull(field.type).name];
2070
2106
  if (!getScope(target?.gqltype, 'find')) return;
@@ -2146,31 +2182,43 @@ const assertEmptinessForm = (gqltype, {
2146
2182
  return true;
2147
2183
  };
2148
2184
 
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.
2185
+ // Every runtime gate validates the received language, before rewriting or filling any markers.
2186
+ // Legacy adapters keep the detailed 2b diagnostics; quantified adapters use the shared validator's
2187
+ // frame-relative checks first. The separate operator inspection includes ignored beside-terms
2188
+ // carriers, which do not become predicates but must not use known undeclared capabilities.
2189
+ const queryFrameContext = (gqltype, args, operation) => ({
2190
+ gqltype, operation, quantifiers: supportsQuantifiers, referenceEmptiness: supportsReferenceEmptiness,
2191
+ operators: supportedOperators,
2192
+ J: operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set(),
2193
+ scopeMarkers: 'pending',
2194
+ });
2195
+
2196
+ const entryType = (gqltype, entry) => {
2197
+ let type = gqltype;
2198
+ const prefix = entry.segments.slice(0, entry.segments.length - entry.relative.length);
2199
+ if (prefix.length) walkQueryPath(gqltype, prefix, ({ field }) => { type = getNamedType(field.type); });
2200
+ return type;
2201
+ };
2202
+
2159
2203
  const assertQueryConditions = (gqltype, args, operation) => {
2160
- const checked = [];
2204
+ if (supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
2205
+ const conditions = [];
2161
2206
  for (const entry of collectQueryOperators(args, operation)) {
2162
2207
  const { operator, path } = entry;
2208
+ const type = entryType(gqltype, entry);
2163
2209
  let shape = null;
2164
2210
  if (isEmptinessOperator(operator)) {
2165
- if (assertEmptinessForm(gqltype, entry)) {
2166
- shape = describeEmptiness(gqltype, entry.segments, {
2167
- operator, value: entry.value, path, referenceEmptiness: supportsReferenceEmptiness,
2211
+ // The validator owns quantified term forms; old top-frame forms retain their diagnostics.
2212
+ if (entry.frames.length || assertEmptinessForm(gqltype, entry)) {
2213
+ if (entry.relative.length) shape = describeEmptiness(type, entry.relative, {
2214
+ operator, value: entry.value, path: entry.relative.join('.'), referenceEmptiness: supportsReferenceEmptiness,
2168
2215
  });
2169
2216
  }
2170
2217
  } else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
2171
- checked.push({ entry, shape });
2218
+ conditions.push({ entry, shape, type });
2172
2219
  }
2173
- return checked;
2220
+ if (!supportsQuantifiers) validateQuantifiedGroups(args, queryFrameContext(gqltype, args, operation));
2221
+ return { conditions, gqltype, args, operation };
2174
2222
  };
2175
2223
 
2176
2224
  // Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
@@ -2187,9 +2235,8 @@ const assertQueryConditions = (gqltype, args, operation) => {
2187
2235
  // the prefixed conditions are applied as trusted ones, which read stored data.
2188
2236
  const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
2189
2237
  const { gqltype } = target;
2190
- const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
2238
+ const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator, frames }) => !frames.length && isEmptinessOperator(operator));
2191
2239
  if (!entries.length) return;
2192
- assertStoredQueryPaths(gqltype, scopeArgs, 'find');
2193
2240
  let throughList = false;
2194
2241
  walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
2195
2242
  // Rule 6, here or on the target's own endpoint, is the rejection thrown below.
@@ -2217,20 +2264,28 @@ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target
2217
2264
  + `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
2218
2265
  };
2219
2266
 
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) => {
2267
+ // Declared collection frames preserve cardinality. A legacy adapter, or a prefix through an
2268
+ // embedded list, still cannot lift collection scopes; vector reference scopes remain forbidden.
2269
+ const assertJoinedScopePaths = (rootType, groups, prefix, target) => {
2270
+ let embeddedPrefix = false;
2271
+ walkQueryPath(rootType, prefix.split('.'), ({ field }) => {
2272
+ embeddedPrefix ||= !!(field.extensions?.relation?.embedded && getListShape(field.type));
2273
+ });
2223
2274
  const depth = prefix.split('.').length;
2224
- for (const segments of collectQueryPaths({ AND: groups })) {
2275
+ for (const { segments, quantifier } of collectQueryPathEntries({ AND: groups })) {
2225
2276
  let inList = false;
2226
- walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field }) => {
2277
+ walkQueryPath(target.gqltype, segments.slice(depth), ({ type, fieldName, field, index }) => {
2227
2278
  const relation = field.extensions?.relation;
2228
2279
  const list = !!getListShape(field.type);
2229
- if (relation && !relation.embedded && (list || inList)) {
2280
+ if (relation && !relation.embedded && (inList || (list && (!supportsQuantifiers || embeddedPrefix)))) {
2230
2281
  throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope filters through `
2231
2282
  + `${list ? 'the collection' : 'the relation'} ${type.name}.${fieldName}${list ? '' : ' inside a list'}`);
2232
2283
  }
2233
- inList ||= list;
2284
+ if (relation && !relation.embedded && quantifier && embeddedPrefix && index === segments.length - depth - 1) {
2285
+ throw forbiddenPath(`Query path ${prefix} enters ${target.gqltype.name}, whose find scope tests `
2286
+ + `the quantified reference ${type.name}.${fieldName} under an embedded-list prefix`);
2287
+ }
2288
+ inList = relation && !relation.embedded ? false : inList || list;
2234
2289
  });
2235
2290
  }
2236
2291
  };
@@ -2244,7 +2299,7 @@ const appendAndGroups = (owner, groups) => {
2244
2299
 
2245
2300
  // The deepest AND/OR group nesting the backends accept; groups listed in args.AND/OR are at depth 0.
2246
2301
  const MAX_FILTER_GROUP_DEPTH = 5;
2247
- const nestedGroups = (group) => [...[].concat(group.AND ?? []), ...[].concat(group.OR ?? [])];
2302
+ const nestedGroups = (group) => ['AND', 'OR', ...QUANTIFIED_LISTS].flatMap((key) => [].concat(group[key] ?? []));
2248
2303
  const groupDepth = (group) => Math.max(0, ...nestedGroups(group)
2249
2304
  .filter((item) => item && typeof item === 'object').map((item) => groupDepth(item) + 1));
2250
2305
 
@@ -2271,10 +2326,13 @@ const applyJoinedScopes = async (joinedScopes, params) => {
2271
2326
  }] of joinedScopes) {
2272
2327
  const scopeArgs = {};
2273
2328
  await executeScope({ type: target, args: scopeArgs, operation: 'find', context: params.context });
2274
- const groups = prefixScopeArgs(scopeArgs, prefix, target.gqltype.name);
2329
+ const kept = scopeFilterArgs(scopeArgs);
2330
+ assertStoredQueryPaths(target.gqltype, kept, 'find');
2331
+ assertQueryConditions(target.gqltype, kept, 'find');
2332
+ const groups = prefixScopeArgs(kept, prefix, target.gqltype.name, copyQuantifiedGroup);
2275
2333
  if (!groups.length) continue;
2276
2334
  if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
2277
- assertJoinedScopePaths(groups, prefix, target);
2335
+ assertJoinedScopePaths(params.type.gqltype, groups, prefix, target);
2278
2336
  assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
2279
2337
  queryGroups ??= collectFilterGroups(params.args);
2280
2338
  const addedDepth = 1 + Math.max(...groups.map(groupDepth));
@@ -2284,60 +2342,93 @@ const applyJoinedScopes = async (joinedScopes, params) => {
2284
2342
  }
2285
2343
  };
2286
2344
 
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.
2345
+ // Client scope provenance is carried by terms, conditions and explicit frames, never by plain
2346
+ // logical groups or values. Only reads of registered scoped targets are marked. Middleware sees
2347
+ // ordinal IDs; after detach and final validation, fill adds relative target-scope arguments. The
2348
+ // planner/compiler rewrite, not the runtime, splits those keys across implicit collection frames.
2297
2349
  const SCOPE_MARKER = '__scope';
2298
2350
  const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
2299
2351
  + 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
2300
2352
  const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
2301
2353
  const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
2302
2354
 
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;
2355
+ // Relation metadata is position-relative when used for fill and absolute for eligibility/recording.
2356
+ const pathRelations = (gqltype, segments) => {
2357
+ const found = [];
2358
+ let insideList = false;
2359
+ walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
2360
+ const relation = field.extensions?.relation;
2361
+ const list = !!getListShape(field.type);
2362
+ if (relation && !relation.embedded) found.push({
2363
+ path: segments.slice(0, index + 1).join('.'), index,
2364
+ typeName: getNamedType(field.type).name, collection: list, insideList, owner: type.name, fieldName,
2365
+ });
2366
+ // A collection enters an element frame; only embedded lists make vector reference joins.
2367
+ if (relation && !relation.embedded && list) insideList = false;
2368
+ else if (!relation || relation.embedded) insideList ||= list;
2369
+ });
2370
+ return found;
2371
+ };
2372
+
2373
+ // The walker retains scalar-filter terms for capability/auth inspection, but no compiler consumes
2374
+ // them. Such positions cannot establish client provenance, including when aliased with a predicate.
2375
+ const ignoredScalarTerm = (gqltype, entry) => {
2376
+ if (entry.form !== 'term' || entry.frames.length || typeof entry.field !== 'string') return false;
2377
+ const segments = entry.field.split('.');
2378
+ let scalar = false;
2379
+ walkQueryPath(gqltype, segments, ({ field, index }) => {
2380
+ if (index === segments.length - 1) scalar = !isObjectType(getNamedType(field.type));
2381
+ });
2382
+ return scalar;
2383
+ };
2384
+
2385
+ const ordinaryFrameCarrier = (gqltype, entry, J) => supportsQuantifiers && entry.construct
2386
+ && !ignoredScalarTerm(gqltype, entry)
2387
+ && entry.form !== 'beside-terms' && (entry.frames.length || pathRelations(gqltype, entry.segments)
2388
+ .some(({ path, collection }) => collection && !J.has(path)));
2389
+
2390
+ // Eligibility is per carrier: ordinary legacy reference-only/all-J predicates keep joined scopes.
2391
+ // Enclosing explicit frames inherit provenance from marked descendants, independently of unit counts.
2392
+ // A view continues issuance on the same map, but checks its own compiled-unit budget (as in 3.12.0).
2393
+ const markClientConstructs = (checked, { aggregation, issued = new Map() } = {}) => {
2394
+ const { gqltype, args, operation, conditions } = checked;
2395
+ const clientJ = operation === 'aggregate' ? aggregateJoinPrefixes(aggregation, { gqltype }) : new Set();
2396
+ if (!supportsReferenceEmptiness) return { issued: null, clientJ };
2397
+ const mark = (construct, origin) => {
2398
+ if (!construct || hasScopeMarker(construct)) return;
2399
+ const id = issued.size;
2321
2400
  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}`);
2401
+ issued.set(id, origin);
2402
+ };
2403
+ for (const { entry, shape } of conditions) {
2404
+ const { construct } = entry;
2405
+ if (!construct || !(shape || entry.form === 'quantified' || ordinaryFrameCarrier(gqltype, entry, clientJ))) continue;
2406
+ const targets = pathRelations(gqltype, entry.segments).map(({ typeName }) => typeName);
2407
+ if (shape) targets.push(...Object.values(emptinessScopeTargets(shape, entry.relative)));
2408
+ if (!targets.some((name) => getScope(typesDict.types[name]?.gqltype, 'find'))) continue;
2409
+ const origin = { operator: entry.operator ?? entry.quantifier, path: entry.path };
2410
+ mark(construct, origin);
2411
+ const prefix = [];
2412
+ for (const frame of entry.frames) {
2413
+ prefix.push(...frame.path.split('.'));
2414
+ mark(frame.construct, { operator: frame.type, path: prefix.join('.') });
2415
+ }
2325
2416
  }
2417
+ const { count } = frameUnits(args, queryFrameContext(gqltype, args, operation));
2326
2418
  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);
2419
+ if (count > limit) {
2420
+ throw new SimfinityError(`The client's conditions need ${count} frames and scoped joins in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
2331
2421
  }
2332
- return markers;
2422
+ return { issued, clientJ };
2333
2423
  };
2334
2424
 
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) => {
2425
+ // Every issued ID must survive on a valid carrier, with no forged or prefilled marker. Marked
2426
+ // descendants may only live inside marked client frames; top-frame collection prefixes keep J.
2427
+ const assertClientMarkers = (gqltype, args, operation, issued, clientJ) => {
2428
+ const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
2339
2429
  const found = new Set();
2340
- for (const { construct, operator, path } of collectQueryOperators(args, operation)) {
2430
+ for (const entry of collectQueryOperators(args, operation)) {
2431
+ const { construct, operator, path } = entry;
2341
2432
  if (!hasScopeMarker(construct)) continue;
2342
2433
  const marker = construct[SCOPE_MARKER];
2343
2434
  const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
@@ -2348,6 +2439,17 @@ const assertClientMarkers = (args, operation, issued) => {
2348
2439
  if (!origin) {
2349
2440
  throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
2350
2441
  }
2442
+ if (ignoredScalarTerm(gqltype, entry)) {
2443
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is in ignored scalar filter terms`);
2444
+ }
2445
+ if (entry.frames.some((frame) => !hasScopeMarker(frame.construct))) {
2446
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is inside a trusted frame`);
2447
+ }
2448
+ if (supportsQuantifiers && !entry.frames.length && entry.form !== 'quantified' && !isEmptinessOperator(operator)) {
2449
+ for (const relation of pathRelations(gqltype, entry.segments)) if (relation.collection && clientJ.has(relation.path) !== J.has(relation.path)) {
2450
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} changed aggregate join prefixes`);
2451
+ }
2452
+ }
2351
2453
  found.add(marker.id);
2352
2454
  }
2353
2455
  for (const [id, origin] of issued) {
@@ -2368,6 +2470,15 @@ const relationOnPath = (gqltype, segments) => {
2368
2470
  return found;
2369
2471
  };
2370
2472
 
2473
+ const scopeFilterArgs = (scopeArgs) => {
2474
+ const kept = {};
2475
+ for (const [name, value] of Object.entries(scopeArgs)) {
2476
+ if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
2477
+ else if (value != null) assertScopeControlArg(name, value);
2478
+ }
2479
+ return kept;
2480
+ };
2481
+
2371
2482
  // The find scope arguments of a type that a client's constructs read through, once per call and type
2372
2483
  // (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
2373
2484
  // joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
@@ -2380,11 +2491,7 @@ const clientScopeArgs = async (typeName, context, cache) => {
2380
2491
  if (getScope(target?.gqltype, 'find')) {
2381
2492
  const scopeArgs = {};
2382
2493
  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
- }
2494
+ const kept = scopeFilterArgs(scopeArgs);
2388
2495
  assertStoredQueryPaths(target.gqltype, kept, 'find');
2389
2496
  assertQueryConditions(target.gqltype, kept, 'find');
2390
2497
  if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
@@ -2393,19 +2500,15 @@ const clientScopeArgs = async (typeName, context, cache) => {
2393
2500
  return result;
2394
2501
  };
2395
2502
 
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.
2503
+ // Preflight every final position before running target scopes: union relative keys, reject target
2504
+ // collisions and mixed reads, and retain the compiler's rejection of marked legacy predicates.
2505
+ // J collection keys are null; anchor references remain legacy joins. Keyless carriers lose their
2506
+ // marker. One per-call type cache serves all remaining keys, separately from joined-scope execution.
2406
2507
  const fillClientScopes = async (gqltype, checked, context, issued) => {
2508
+ const { conditions, args, operation } = checked;
2509
+ const J = operation === 'aggregate' ? aggregateJoinPrefixes(args.aggregation, { gqltype }) : new Set();
2407
2510
  const marked = new Map();
2408
- for (const item of checked) {
2511
+ for (const item of conditions) {
2409
2512
  const { construct } = item.entry;
2410
2513
  if (!hasScopeMarker(construct)) continue;
2411
2514
  if (!marked.has(construct)) marked.set(construct, []);
@@ -2417,18 +2520,29 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2417
2520
  const origin = issued.get(id);
2418
2521
  const targets = {};
2419
2522
  const unread = [];
2420
- for (const { entry, shape } of positions) {
2421
- if (!entry.emptiness) {
2523
+ const vectors = [];
2524
+ for (const { entry, shape, type } of positions) {
2525
+ const relations = pathRelations(type, entry.relative);
2526
+ if (!shape && entry.form !== 'quantified' && !ordinaryFrameCarrier(gqltype, entry, J)) {
2422
2527
  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`);
2528
+ if (relation) throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
2529
+ + `${entry.path}, which reads through ${relation} without its scope`);
2530
+ }
2531
+ const rootShorthand = !entry.frames.length && entry.form !== 'quantified' && !shape;
2532
+ const anchored = rootShorthand && relations.some(({ collection, path }) => collection && J.has(path));
2533
+ const cut = anchored ? relations.find(({ collection, path }) => collection && !J.has(path)) : null;
2534
+ const keys = shape ? emptinessScopeTargets(shape, entry.relative) : Object.fromEntries(relations
2535
+ .filter((relation) => !anchored || relation.collection || (cut && relation.index >= cut.index))
2536
+ .map(({ path, typeName, collection }) => [path, rootShorthand && collection && J.has(path) ? null : typeName]));
2537
+ for (const [key, target] of Object.entries(keys)) {
2538
+ if (Object.hasOwn(targets, key) && targets[key] !== target) {
2539
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} uses ${key} for different scope targets`);
2426
2540
  }
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);
2541
+ targets[key] = target;
2542
+ }
2543
+ if (!Object.keys(keys).length) unread.push(entry.path);
2544
+ for (const relation of relations) if (!relation.collection && relation.insideList && Object.hasOwn(keys, relation.path)) {
2545
+ vectors.push({ ...relation, absolute: [...entry.segments.slice(0, -entry.relative.length), ...relation.path.split('.')].join('.') });
2432
2546
  }
2433
2547
  }
2434
2548
  const paths = Object.keys(targets);
@@ -2436,16 +2550,16 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2436
2550
  throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
2437
2551
  + `references or collections and where it reads none (${unread.join(', ')})`);
2438
2552
  }
2439
- fills.push({ construct, id, targets: paths.length ? targets : null });
2553
+ fills.push({ construct, id, targets: paths.length ? targets : null, vectors });
2440
2554
  }
2441
2555
  const cache = new Map();
2442
- for (const { construct, id, targets } of fills) {
2443
- if (!targets) {
2444
- delete construct[SCOPE_MARKER];
2445
- continue;
2446
- }
2556
+ for (const { construct, id, targets, vectors } of fills) {
2557
+ if (!targets) { delete construct[SCOPE_MARKER]; continue; }
2447
2558
  const scopes = {};
2448
- for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
2559
+ for (const [path, typeName] of Object.entries(targets)) scopes[path] = typeName === null ? null : await clientScopeArgs(typeName, context, cache);
2560
+ for (const relation of vectors) if (scopes[relation.path] !== null) {
2561
+ throw forbiddenPath(`Query path ${relation.absolute} enters ${relation.typeName}, whose find scope restricts the relation ${relation.owner}.${relation.fieldName} inside a list`);
2562
+ }
2449
2563
  construct[SCOPE_MARKER] = { id, scopes };
2450
2564
  }
2451
2565
  };
@@ -2455,8 +2569,8 @@ const fillClientScopes = async (gqltype, checked, context, issued) => {
2455
2569
  // the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
2456
2570
  // Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
2457
2571
  // the adapter after as many promise jobs as before.
2458
- const settleClientArgs = (gqltype, params, operation, issued) => {
2459
- if (issued) assertClientMarkers(params.args, operation, issued);
2572
+ const settleClientArgs = (gqltype, params, operation, { issued, clientJ }) => {
2573
+ if (issued) assertClientMarkers(gqltype, params.args, operation, issued, clientJ);
2460
2574
  assertStoredQueryPaths(gqltype, params.args, operation);
2461
2575
  const checked = assertQueryConditions(gqltype, params.args, operation);
2462
2576
  return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
@@ -3073,7 +3187,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
3073
3187
  // without one still reaches the adapter after as many promise jobs as before it was shared.
3074
3188
  const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
3075
3189
  const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
3076
- const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
3190
+ const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation), { aggregation: args.aggregation });
3077
3191
  const params = entry === undefined
3078
3192
  ? { type, args, operation, context }
3079
3193
  : { type, args, operation, entry, context };
@@ -3122,13 +3236,13 @@ const splitViewArgs = (paramNames, args) => {
3122
3236
 
3123
3237
  const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
3124
3238
 
3125
- // The groups a view's `where` returned are plain objects whose `conditions`, `AND` and `OR`, when set,
3239
+ // The groups a view's `where` returned are plain objects whose condition and Boolean/quantified lists, when set,
3126
3240
  // are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
3127
3241
  // 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();
3242
+ // fields are structural keys; condition values stay opaque. Conditions and quantified groups never
3243
+ // carry a client scope marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
3244
+ const assertViewGroupShapes = (view, groups, ancestors = new Set(), quantified = false) => {
3245
+ const groupFields = (quantified ? QLQuantifiedGroup : QLFilterGroup).getFields();
3132
3246
  const conditionFields = QLFilterCondition.getFields();
3133
3247
  for (const group of groups) {
3134
3248
  if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
@@ -3136,7 +3250,11 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
3136
3250
  for (const key of Object.keys(group)) {
3137
3251
  if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
3138
3252
  }
3253
+ if (quantified && (typeof group.path !== 'string' || group.path.length === 0)) {
3254
+ throw invalidWhere(view, 'returned a quantified group without a non-empty path');
3255
+ }
3139
3256
  for (const key of Object.keys(groupFields)) {
3257
+ if (key === 'path') continue;
3140
3258
  if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
3141
3259
  }
3142
3260
  for (const condition of group.conditions ?? []) {
@@ -3152,6 +3270,7 @@ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
3152
3270
  }
3153
3271
  ancestors.add(group);
3154
3272
  assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
3273
+ for (const key of QUANTIFIED_LISTS) assertViewGroupShapes(view, group[key] ?? [], ancestors, true);
3155
3274
  ancestors.delete(group);
3156
3275
  }
3157
3276
  };