@simtlix/simfinity-core 3.9.0 → 3.11.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
@@ -1,10 +1,12 @@
1
- import { configureQueryLimits, getQueryMaxPageSize, paginationStages } from './query-limits.js';
1
+ import {
2
+ configureQueryLimits, getQueryMaxPageSize, getQueryMaxReferenceFrames, paginationStages,
3
+ } from './query-limits.js';
2
4
  import { configureMutationLimits, getMaxNestedOperations } from './mutation-limits.js';
3
5
  import {
4
6
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
5
7
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
6
8
  GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
7
- Kind, getNamedType,
9
+ Kind, getNamedType, isObjectType, isOutputType,
8
10
  } from 'graphql';
9
11
 
10
12
  import SimfinityError from './errors/simfinity.error.js';
@@ -17,6 +19,9 @@ import {
17
19
  collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
18
20
  } from './query-plan.js';
19
21
  import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
22
+ import {
23
+ assertEmptinessPath, describeEmptiness, emptinessScopePaths, emptinessScopeTargets, isEmptinessOperator,
24
+ } from './query-emptiness.js';
20
25
  import { relationFieldOwners } from './relation-owners.js';
21
26
  import { isThenable } from './auth/thenable.js';
22
27
  import {
@@ -178,7 +183,10 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
178
183
  throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
179
184
  }
180
185
  for (const term of value.terms) {
181
- conditions.push({ field: `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
186
+ // An emptiness term without a path (omitted, null or "") tests the field itself; other
187
+ // operators keep the path as written.
188
+ const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
189
+ conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
182
190
  }
183
191
  } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
184
192
  }
@@ -272,13 +280,22 @@ const assertFieldMetadataNamesFree = (roots) => {
272
280
  if (clash) throw reservedTypeNameError(clash);
273
281
  };
274
282
 
283
+ const buildSchemaConfig = (query, mutation) => {
284
+ const config = { query };
285
+ if (Object.keys(mutation.getFields()).length > 0) {
286
+ config.mutation = mutation;
287
+ }
288
+ return config;
289
+ };
290
+
275
291
  // Constructs the schema of these roots. When one of its types has the name of a field metadata type,
276
292
  // those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
277
293
  // when the schema also has those names, in every schema the process constructs from then on. A
278
294
  // construction that fails puts the previous metadata types back, so only a schema that exists fixes
279
295
  // the names.
280
296
  const constructSchema = (query, mutation) => {
281
- const roots = [query, mutation];
297
+ const config = buildSchemaConfig(query, mutation);
298
+ const roots = Object.values(config);
282
299
  const clash = fieldMetadataClash(roots);
283
300
  if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
284
301
  let names = null;
@@ -293,7 +310,7 @@ const constructSchema = (query, mutation) => {
293
310
  const restore = names ? renameFieldMetadataTypes(...names) : null;
294
311
  let schema;
295
312
  try {
296
- schema = new GraphQLSchema({ query, mutation });
313
+ schema = new GraphQLSchema(config);
297
314
  } catch (error) {
298
315
  if (restore) restore();
299
316
  throw error;
@@ -455,8 +472,12 @@ export const createRuntime = (adapter) => {
455
472
  throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
456
473
  }
457
474
  // The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
458
- // operators when it declares none.
459
- const supportedOperators = resolveQueryFeatures(adapter.queryFeatures).operators;
475
+ // operators when it declares none. With queryFeatures.referenceEmptiness, it also compiles
476
+ // IS_EMPTY and IS_NOT_EMPTY through references and collections (#108 part 2b), and a client's
477
+ // conditions of that kind carry a scope marker from the runtime (see markClientConstructs).
478
+ const {
479
+ operators: supportedOperators, referenceEmptiness: supportsReferenceEmptiness,
480
+ } = resolveQueryFeatures(adapter.queryFeatures);
460
481
  const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
461
482
 
462
483
  const stateValue = adapter.stateValue || ((state) => state.name);
@@ -473,6 +494,7 @@ export const createRuntime = (adapter) => {
473
494
  const selfCollectionNameHolders = new Map();
474
495
  const qualifiedSelfCollections = new Map();
475
496
  const registeredMutations = {};
497
+ const registeredQueries = {};
476
498
  const middlewares = [];
477
499
 
478
500
  const operations = {
@@ -481,6 +503,7 @@ export const createRuntime = (adapter) => {
481
503
  DELETE: 'delete',
482
504
  STATE_CHANGED: 'state_changed',
483
505
  CUSTOM_MUTATION: 'custom_mutation',
506
+ CUSTOM_QUERY: 'custom_query',
484
507
  };
485
508
 
486
509
  const use = (middleware) => {
@@ -533,7 +556,7 @@ export const createRuntime = (adapter) => {
533
556
  };
534
557
 
535
558
  // Object and interface types a schema of this runtime can reach from its registered types and
536
- // custom mutation results, following the same references as GraphQLSchema.
559
+ // custom operation results, following the same references as GraphQLSchema.
537
560
  const collectSchemaOutputTypes = () => {
538
561
  const visited = new Set();
539
562
  const outputTypes = [];
@@ -550,8 +573,13 @@ export const createRuntime = (adapter) => {
550
573
  named.getInterfaces().forEach(visit);
551
574
  for (const field of Object.values(named.getFields())) visit(field.type);
552
575
  };
553
- for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
554
- for (const { outputModel } of Object.values(registeredMutations)) visit(outputModel);
576
+ const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
577
+ const customOperations = [
578
+ ...Object.values(registeredMutations),
579
+ ...Object.values(registeredQueries),
580
+ ];
581
+ const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
582
+ [...registeredTypes, ...customOutputTypes].forEach(visit);
555
583
  return outputTypes;
556
584
  };
557
585
 
@@ -1274,10 +1302,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1274
1302
  return { modelArgs, collectionFields };
1275
1303
  };
1276
1304
 
1277
- const executeRegisteredMutation = (args, callback, context, session, inputModel) => {
1305
+ // Custom queries run without a transaction or session; custom mutations run in one, which may retry.
1306
+ const executeRegisteredOperation = (args, registration, operation, context) => {
1307
+ const { inputModel, callback } = registration;
1308
+ if (operation === operations.CUSTOM_QUERY) {
1309
+ return callback(cloneInput(args, inputModel), context);
1310
+ }
1278
1311
  const nextInput = attemptInputs(args, inputModel);
1279
1312
  return adapter.withTransaction(
1280
- session,
1313
+ null,
1281
1314
  (mySession) => callback(nextInput(), mySession, context),
1282
1315
  );
1283
1316
  };
@@ -1725,28 +1758,69 @@ const executeScope = async (params) => {
1725
1758
  return scopeFunction({ type, args, operation, context });
1726
1759
  };
1727
1760
 
1728
- // Copies AND/OR group lists with new group objects and arrays, leaving conditions and other values
1729
- // shared.
1730
- const copyFilterGroups = (groups) => (Array.isArray(groups) ? groups.map((group) => {
1731
- if (!group || typeof group !== 'object' || Array.isArray(group)) return group;
1732
- const copy = { ...group };
1733
- if (Array.isArray(group.conditions)) copy.conditions = [...group.conditions];
1734
- if (Object.hasOwn(group, 'AND')) copy.AND = copyFilterGroups(group.AND);
1735
- if (Object.hasOwn(group, 'OR')) copy.OR = copyFilterGroups(group.OR);
1761
+ // The arguments of a list, aggregate or collection field call that are not filters. On aggregate
1762
+ // endpoints `aggregation` is one; elsewhere a field of that name is filtered like any other.
1763
+ const LIST_CONTROL_ARGS = new Set(['sort', 'pagination']);
1764
+ const AGGREGATE_CONTROL_ARGS = new Set(['sort', 'pagination', 'aggregation']);
1765
+ const isFilterObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
1766
+ // A new object with the same prototype and own enumerable properties: graphql-js builds inline literals
1767
+ // with a null prototype and variables with Object's, and childArgsKey tells them apart.
1768
+ const copyFilterObject = (value) => Object.assign(Object.create(Object.getPrototypeOf(value)), value);
1769
+ // A term, a group condition or a field filter: a value array becomes a new Array with the same items.
1770
+ const copyFilterCondition = (condition) => {
1771
+ if (!isFilterObject(condition)) return condition;
1772
+ const copy = copyFilterObject(condition);
1773
+ if (Array.isArray(condition.value)) copy.value = [...condition.value];
1736
1774
  return copy;
1737
- }) : groups);
1775
+ };
1776
+ const copyFilterGroup = (group) => {
1777
+ if (!isFilterObject(group)) return group;
1778
+ const copy = copyFilterObject(group);
1779
+ if (Array.isArray(group.conditions)) copy.conditions = group.conditions.map(copyFilterCondition);
1780
+ if (Array.isArray(group.AND)) copy.AND = group.AND.map(copyFilterGroup);
1781
+ if (Array.isArray(group.OR)) copy.OR = group.OR.map(copyFilterGroup);
1782
+ return copy;
1783
+ };
1784
+ const copyFieldFilter = (filter) => {
1785
+ const copy = copyFilterCondition(filter);
1786
+ if (isFilterObject(filter) && Array.isArray(filter.terms)) copy.terms = filter.terms.map(copyFilterCondition);
1787
+ return copy;
1788
+ };
1789
+
1790
+ // A tree copy of the query-language structure of one call's filter arguments, onto that call's args:
1791
+ // field filters and their terms, AND/OR groups and their conditions. Every place gets its own object,
1792
+ // also where graphql-js hands one variable object to several arguments or list items, so that a
1793
+ // middleware edit or a client scope marker stays within the call. Value arrays are copied shallowly,
1794
+ // and every other value, sort, pagination and aggregation included, is kept as it is. GraphQL input
1795
+ // has no cycles, so nothing is memoized.
1796
+ const copyClientFilters = (args, operation) => {
1797
+ const controls = operation === 'aggregate' ? AGGREGATE_CONTROL_ARGS : LIST_CONTROL_ARGS;
1798
+ for (const [name, value] of Object.entries(args)) {
1799
+ if (name === 'AND' || name === 'OR') {
1800
+ if (Array.isArray(value)) args[name] = value.map(copyFilterGroup);
1801
+ } else if (!controls.has(name)) args[name] = copyFieldFilter(value);
1802
+ }
1803
+ };
1738
1804
 
1739
1805
  // Checks the filter, sort and aggregation paths a client supplied, before middleware and scopes
1740
1806
  // add trusted paths. Returns the scoped relations those paths enter, keyed by path, with the
1741
1807
  // client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
1742
1808
  // graphql-js passes the same variable values to every resolver call of a request, so the client's
1743
- // AND/OR groups are first copied onto this call's args, and joined scopes are only placed in those copies.
1809
+ // filters are first copied onto this call's args (copyClientFilters), and joined scopes are only
1810
+ // placed in those copies.
1811
+ // An emptiness condition on an embedded object reads its stored members, so their paths are checked
1812
+ // as client paths too (#108 part 2a); a member path only gets the 403 checks, and enters no joined
1813
+ // scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
1814
+ // condition reads its references and collections through scoped joins and frames instead, so it
1815
+ // enters no joined scope either; every other entry, beside it or on the same path, records its own.
1744
1816
  const inspectClientPaths = (gqltype, args, operation) => {
1745
- for (const key of ['AND', 'OR']) {
1746
- if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
1747
- }
1817
+ copyClientFilters(args, operation);
1748
1818
  const joinedScopes = new Map();
1749
- for (const { segments, group } of collectQueryPathEntries(args, operation)) {
1819
+ const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
1820
+ for (const {
1821
+ segments, group, member, operator,
1822
+ } of entries) {
1823
+ const joins = !member && !(supportsReferenceEmptiness && isEmptinessOperator(operator));
1750
1824
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1751
1825
  const path = segments.slice(0, index + 1).join('.');
1752
1826
  if (isVirtualField(field)) {
@@ -1756,6 +1830,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
1756
1830
  if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
1757
1831
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
1758
1832
  }
1833
+ if (!joins) return;
1759
1834
  const relation = field.extensions?.relation;
1760
1835
  if (!relation || relation.embedded) return;
1761
1836
  const target = typesDict.types[unwrapListAndNonNull(field.type).name];
@@ -1794,19 +1869,119 @@ const assertStoredQueryPaths = (gqltype, args, operation) => {
1794
1869
  }
1795
1870
  };
1796
1871
 
1872
+ const assertDeclaredOperator = (gqltype, operator, path) => {
1873
+ if (!supportedOperators.has(operator)) {
1874
+ throw new SimfinityError(`The database adapter does not support the ${operator} filter operator, used on `
1875
+ + `${gqltype.name}.${path}`, 'UNSUPPORTED_QUERY', 400);
1876
+ }
1877
+ };
1878
+
1879
+ // The checks of an emptiness condition (#108 part 2a) that come before rules 3 to 6 of
1880
+ // describeEmptiness, with the codes and messages of createQueryPlan: the group depth, a dotted field
1881
+ // next to a path, an emptiness operator on an object filter without terms or beside terms, the
1882
+ // fields of the path (rule 1), then the adapter's operators (rule 2). Returns false when the backends
1883
+ // never read the condition as one, so that rules 3 to 6 do not apply: a term of a field that is not
1884
+ // an object (they read that filter's own operator) and a condition without a string field (they
1885
+ // reject it their own way; when the adapter does not declare the operator, no compiler sees it, so
1886
+ // the gate gives createQueryPlan's rule-1 code).
1887
+ const assertEmptinessForm = (gqltype, {
1888
+ operator, path, segments, form, field, depth, ambiguous,
1889
+ }) => {
1890
+ if (form === 'condition') {
1891
+ if (depth > MAX_FILTER_GROUP_DEPTH) throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
1892
+ if (typeof field !== 'string') {
1893
+ if (!supportedOperators.has(operator)) throw new SimfinityError('Invalid filter path', 'INVALID_FILTER_PATH', 400);
1894
+ return false;
1895
+ }
1896
+ if (ambiguous) {
1897
+ throw new SimfinityError('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD', 400);
1898
+ }
1899
+ } else {
1900
+ assertEmptinessPath(gqltype, [field]);
1901
+ const type = getNamedType(gqltype.getFields()[field].type);
1902
+ if (isObjectType(type) && form !== 'term') {
1903
+ throw new SimfinityError(`Filter on ${field} takes ${operator} only in a term; to test the field itself, use a term `
1904
+ + `without a path: ${field}: { terms: [{ operator: ${operator} }] }`, 'MISSING_FILTER_PATH', 400);
1905
+ }
1906
+ if (form === 'term' && !isObjectType(type) && !(type instanceof GraphQLInterfaceType) && !(type instanceof GraphQLUnionType)) {
1907
+ assertDeclaredOperator(gqltype, operator, path);
1908
+ return false;
1909
+ }
1910
+ }
1911
+ assertEmptinessPath(gqltype, segments, { path });
1912
+ assertDeclaredOperator(gqltype, operator, path);
1913
+ return true;
1914
+ };
1915
+
1797
1916
  // Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
1798
- // UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile.
1799
- // Runs on the client's arguments before middleware and on the final arguments before the adapter
1800
- // call, which covers what middleware, scopes and joined scopes added. Operators that QLOperator does
1801
- // not list are left to the adapter, which reports them as before.
1802
- const assertSupportedOperators = (gqltype, args, operation) => {
1803
- if (supportsEveryOperator) return;
1804
- for (const { operator, path } of collectQueryOperators(args, operation)) {
1805
- if (isQueryOperator(operator) && !supportedOperators.has(operator)) {
1806
- throw new SimfinityError(`The database adapter does not support the ${operator} filter operator, used on `
1807
- + `${gqltype.name}.${path}`, 'UNSUPPORTED_QUERY', 400);
1917
+ // UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
1918
+ // and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
1919
+ // whatever the adapter declares, rule 6 only when it does not declare reference emptiness. Runs on
1920
+ // the client's arguments before middleware and on the final arguments before the adapter call,
1921
+ // which covers what middleware, scopes and joined scopes added, so that an emptiness condition gets
1922
+ // the code that a direct createQueryPlan call gives, whoever wrote it and whatever the caller may
1923
+ // read. Operators that QLOperator does not list are left to the adapter, which reports them as before.
1924
+ // Returns every condition as `{ entry, shape }`: its collectQueryOperators entry, and the shape of an
1925
+ // emptiness condition that the backends read as one, or null.
1926
+ const assertQueryConditions = (gqltype, args, operation) => {
1927
+ const checked = [];
1928
+ for (const entry of collectQueryOperators(args, operation)) {
1929
+ const { operator, path } = entry;
1930
+ let shape = null;
1931
+ if (isEmptinessOperator(operator)) {
1932
+ if (assertEmptinessForm(gqltype, entry)) {
1933
+ shape = describeEmptiness(gqltype, entry.segments, {
1934
+ operator, value: entry.value, path, referenceEmptiness: supportsReferenceEmptiness,
1935
+ });
1936
+ }
1937
+ } else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
1938
+ checked.push({ entry, shape });
1939
+ }
1940
+ return checked;
1941
+ };
1942
+
1943
+ // Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
1944
+ // client's relation path, because the prefix makes the condition cross a reference, which 2a
1945
+ // rejects. Runs after the scoped-collection and assertJoinedScopePaths 403s. The raw scope arguments,
1946
+ // operators beside terms included, first get the checks they get on the target's own endpoint (the
1947
+ // stored paths, then each emptiness condition's), so that a rejection the scope has there keeps its
1948
+ // code; otherwise the request fails with UNSUPPORTED_QUERY naming the scope and the prefixed condition.
1949
+ // A prefix that continues inside an embedded list or a collection (`olines.shop`, `reviews.item`)
1950
+ // adds more than a reference crossing. After its form and operator, each prefixed condition is checked
1951
+ // on `rootType`, the queried type, where rule 3, which comes before rules 4 to 6, rejects it with the
1952
+ // permanent INVALID_FILTER_PATH that the final gate and createQueryPlan give it wherever it is written.
1953
+ // An adapter that compiles reference emptiness lifts decision 3 (#108 part 2b): after the same checks,
1954
+ // the prefixed conditions are applied as trusted ones, which read stored data.
1955
+ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
1956
+ const { gqltype } = target;
1957
+ const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
1958
+ if (!entries.length) return;
1959
+ assertStoredQueryPaths(gqltype, scopeArgs, 'find');
1960
+ let throughList = false;
1961
+ walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
1962
+ // Rule 6, here or on the target's own endpoint, is the rejection thrown below.
1963
+ const exceptRule6 = (check) => {
1964
+ try {
1965
+ check();
1966
+ } catch (error) {
1967
+ if (!(error instanceof SimfinityError) || error.getCode() !== 'UNSUPPORTED_QUERY') throw error;
1808
1968
  }
1969
+ };
1970
+ const referenceEmptiness = supportsReferenceEmptiness;
1971
+ for (const entry of entries) {
1972
+ if (!assertEmptinessForm(gqltype, entry)) continue;
1973
+ const { operator, value } = entry;
1974
+ if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value, referenceEmptiness }));
1975
+ exceptRule6(() => describeEmptiness(gqltype, entry.segments, {
1976
+ operator, value, path: entry.path, referenceEmptiness,
1977
+ }));
1809
1978
  }
1979
+ if (referenceEmptiness) return;
1980
+ const prefixed = collectQueryOperators({ AND: groups }).find(({ operator }) => isEmptinessOperator(operator))
1981
+ ?? { operator: entries[0].operator, path: `${prefix}.${entries[0].path}` };
1982
+ throw new SimfinityError(`Query path ${prefix} enters ${gqltype.name}, whose find scope tests ${prefixed.path} with `
1983
+ + `${prefixed.operator}: the database adapter does not support emptiness through references and collections; `
1984
+ + `use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
1810
1985
  };
1811
1986
 
1812
1987
  // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
@@ -1867,6 +2042,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
1867
2042
  if (!groups.length) continue;
1868
2043
  if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
1869
2044
  assertJoinedScopePaths(groups, prefix, target);
2045
+ assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
1870
2046
  queryGroups ??= collectFilterGroups(params.args);
1871
2047
  const addedDepth = 1 + Math.max(...groups.map(groupDepth));
1872
2048
  if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
@@ -1875,6 +2051,181 @@ const applyJoinedScopes = async (joinedScopes, params) => {
1875
2051
  }
1876
2052
  };
1877
2053
 
2054
+ // Client scope markers (#108 part 2b). When the adapter compiles reference emptiness, each IS_EMPTY or
2055
+ // IS_NOT_EMPTY term or group condition that a client sends and that crosses or ends at a reference or a
2056
+ // collection, or reads one as a member, gets `__scope: { id }` on this call's copy, before middleware.
2057
+ // After middleware, the query's own scope and joined scopes, the runtime checks that every marker it
2058
+ // issued is still there and that no other one is, gates the final arguments, then fills each marker
2059
+ // with the raw find scope arguments of every type the construct reads through, `__scope: { id, scopes:
2060
+ // { [path]: args | null } }`, which the compilers apply as scoped joins and frames, so that a record
2061
+ // those scopes hide reads as missing. Constructs without a marker are trusted and read stored data.
2062
+ // Markers live only on the terms and group conditions collectQueryOperators returns; a `__scope`
2063
+ // anywhere else, inside a value for one, is never read.
2064
+ const SCOPE_MARKER = '__scope';
2065
+ const DETACHED_ADVICE = 'middleware and scopes must keep the client\'s filter objects: copy them instead of rebuilding '
2066
+ + 'them, and append restrictions instead of assigning over them (docs/guide/middleware.md)';
2067
+ const detachedClientFilter = (message) => new SimfinityError(`${message}; ${DETACHED_ADVICE}`, 'DETACHED_CLIENT_FILTER', 500);
2068
+ const hasScopeMarker = (construct) => construct != null && Object.hasOwn(construct, SCOPE_MARKER);
2069
+
2070
+ // Marks the client's constructs that read references or collections, from the gate's conditions on the
2071
+ // client's arguments, then applies maxReferenceFrames to the distinct (kind, path) pairs of their
2072
+ // scoped joins and frames in this call, so every 2a code comes first. Returns the issued markers, as
2073
+ // id -> the client's operator and path, or null when the adapter does not compile reference
2074
+ // emptiness. Ids are ordinals in the order of the conditions, so identical arguments get identical
2075
+ // markers and sibling collection reads keep one batch key.
2076
+ const markClientConstructs = (checked) => {
2077
+ if (!supportsReferenceEmptiness) return null;
2078
+ const issued = new Map();
2079
+ const pairs = new Set();
2080
+ for (const { entry, shape } of checked) {
2081
+ const { construct } = entry;
2082
+ if (!shape || !construct || hasScopeMarker(construct)) continue;
2083
+ const { joins, frames } = emptinessScopePaths(shape, entry.segments);
2084
+ if (!joins.length && !frames.length) continue;
2085
+ const id = issued.size;
2086
+ construct[SCOPE_MARKER] = { id };
2087
+ issued.set(id, { operator: entry.operator, path: entry.path });
2088
+ for (const path of joins) pairs.add(`join ${path}`);
2089
+ for (const path of frames) pairs.add(`frame ${path}`);
2090
+ }
2091
+ const limit = getQueryMaxReferenceFrames();
2092
+ if (pairs.size > limit) {
2093
+ throw new SimfinityError(`Emptiness conditions through references and collections need ${pairs.size} scoped joins and `
2094
+ + `frames in one query field, more than the limit of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
2095
+ }
2096
+ return issued;
2097
+ };
2098
+
2099
+ // The detach check: every marker issued in this call sits, well formed (an object whose only own key
2100
+ // is its id), on a term or group condition of the final arguments, possibly on several when middleware
2101
+ // copied or duplicated the construct; no other marker sits on one.
2102
+ const assertClientMarkers = (args, operation, issued) => {
2103
+ const found = new Set();
2104
+ for (const { construct, operator, path } of collectQueryOperators(args, operation)) {
2105
+ if (!hasScopeMarker(construct)) continue;
2106
+ const marker = construct[SCOPE_MARKER];
2107
+ const origin = isFilterObject(marker) ? issued.get(marker.id) : undefined;
2108
+ if (!isFilterObject(marker) || Reflect.ownKeys(marker).length !== 1 || !Object.hasOwn(marker, 'id')) {
2109
+ const named = origin ? `, the client's ${origin.operator} condition on ${origin.path},` : '';
2110
+ throw detachedClientFilter(`The ${operator} condition on ${path}${named} carries a malformed client scope marker`);
2111
+ }
2112
+ if (!origin) {
2113
+ throw detachedClientFilter(`The ${operator} condition on ${path} carries a client scope marker that this query field did not issue`);
2114
+ }
2115
+ found.add(marker.id);
2116
+ }
2117
+ for (const [id, origin] of issued) {
2118
+ if (!found.has(id)) {
2119
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is no longer in the arguments `
2120
+ + 'after middleware and scopes');
2121
+ }
2122
+ }
2123
+ };
2124
+
2125
+ // The first reference or collection a path crosses or ends at, as its dotted path, or null.
2126
+ const relationOnPath = (gqltype, segments) => {
2127
+ let found = null;
2128
+ walkQueryPath(gqltype, segments, ({ field, index }) => {
2129
+ const relation = field.extensions?.relation;
2130
+ if (!found && relation && !relation.embedded) found = segments.slice(0, index + 1).join('.');
2131
+ });
2132
+ return found;
2133
+ };
2134
+
2135
+ // The find scope arguments of a type that a client's constructs read through, once per call and type
2136
+ // (`cache`): its find scope runs with empty arguments, operation 'find' and the request context, as
2137
+ // joined scopes do; its sort and pagination are checked for shape and dropped; the rest gets the final
2138
+ // gate on the type, where everything is trusted. Null when the type has no find scope or the scope
2139
+ // added nothing.
2140
+ const clientScopeArgs = async (typeName, context, cache) => {
2141
+ if (cache.has(typeName)) return cache.get(typeName);
2142
+ const target = typesDict.types[typeName];
2143
+ let result = null;
2144
+ if (getScope(target?.gqltype, 'find')) {
2145
+ const scopeArgs = {};
2146
+ await executeScope({ type: target, args: scopeArgs, operation: 'find', context });
2147
+ const kept = {};
2148
+ for (const [name, value] of Object.entries(scopeArgs)) {
2149
+ if (!SCOPE_IGNORED_ARGS.has(name)) kept[name] = value;
2150
+ else if (value != null) assertScopeControlArg(name, value);
2151
+ }
2152
+ assertStoredQueryPaths(target.gqltype, kept, 'find');
2153
+ assertQueryConditions(target.gqltype, kept, 'find');
2154
+ if (Object.values(kept).some((value) => value != null && !(Array.isArray(value) && !value.length))) result = kept;
2155
+ }
2156
+ cache.set(typeName, result);
2157
+ return result;
2158
+ };
2159
+
2160
+ // The fill, on the final arguments the gate passed (`checked`). Each marked object is read at each of
2161
+ // its final positions, with its final operator and path:
2162
+ // - an emptiness condition that crosses, ends at or has as members references or collections gets a
2163
+ // fresh `__scope: { id, scopes }`, one key per scoped join and frame path, with the union of the
2164
+ // keys of every position when middleware placed the object at several;
2165
+ // - an emptiness condition narrowed to the record's own fields loses its marker, and is trusted;
2166
+ // - another operator loses it too, unless its path still crosses or ends at a reference or a
2167
+ // collection: no joined scope was recorded for it, so it fails with DETACHED_CLIENT_FILTER.
2168
+ // An object that reads references at one position and none at another fails the same way, since one
2169
+ // marker cannot serve both. Every check comes before any target scope runs.
2170
+ const fillClientScopes = async (gqltype, checked, context, issued) => {
2171
+ const marked = new Map();
2172
+ for (const item of checked) {
2173
+ const { construct } = item.entry;
2174
+ if (!hasScopeMarker(construct)) continue;
2175
+ if (!marked.has(construct)) marked.set(construct, []);
2176
+ marked.get(construct).push(item);
2177
+ }
2178
+ const fills = [];
2179
+ for (const [construct, positions] of marked) {
2180
+ const { id } = construct[SCOPE_MARKER];
2181
+ const origin = issued.get(id);
2182
+ const targets = {};
2183
+ const unread = [];
2184
+ for (const { entry, shape } of positions) {
2185
+ if (!entry.emptiness) {
2186
+ const relation = relationOnPath(gqltype, entry.segments);
2187
+ if (relation) {
2188
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} now uses ${entry.operator} on `
2189
+ + `${entry.path}, which reads through ${relation} without its scope`);
2190
+ }
2191
+ unread.push(entry.path);
2192
+ } else {
2193
+ const keys = shape ? emptinessScopeTargets(shape, entry.segments) : {};
2194
+ if (Object.keys(keys).length) Object.assign(targets, keys);
2195
+ else unread.push(entry.path);
2196
+ }
2197
+ }
2198
+ const paths = Object.keys(targets);
2199
+ if (paths.length && unread.length) {
2200
+ throw detachedClientFilter(`The client's ${origin.operator} condition on ${origin.path} is used where it reads `
2201
+ + `references or collections and where it reads none (${unread.join(', ')})`);
2202
+ }
2203
+ fills.push({ construct, id, targets: paths.length ? targets : null });
2204
+ }
2205
+ const cache = new Map();
2206
+ for (const { construct, id, targets } of fills) {
2207
+ if (!targets) {
2208
+ delete construct[SCOPE_MARKER];
2209
+ continue;
2210
+ }
2211
+ const scopes = {};
2212
+ for (const [path, typeName] of Object.entries(targets)) scopes[path] = await clientScopeArgs(typeName, context, cache);
2213
+ construct[SCOPE_MARKER] = { id, scopes };
2214
+ }
2215
+ };
2216
+
2217
+ // After middleware, the query's own scope and joined scopes, and before the adapter call: the detach
2218
+ // check, the final gate (stored paths, then every condition), then the fill. Markers exist only when
2219
+ // the adapter compiles reference emptiness. get_by_id builds its own arguments and never runs it.
2220
+ // Returns the fill's promise, or null when no marker was issued, so that a call without one reaches
2221
+ // the adapter after as many promise jobs as before.
2222
+ const settleClientArgs = (gqltype, params, operation, issued) => {
2223
+ if (issued) assertClientMarkers(params.args, operation, issued);
2224
+ assertStoredQueryPaths(gqltype, params.args, operation);
2225
+ const checked = assertQueryConditions(gqltype, params.args, operation);
2226
+ return issued?.size ? fillClientScopes(gqltype, checked, params.context, issued) : null;
2227
+ };
2228
+
1878
2229
  // Unscoped relation reads of one request, grouped by related type until the promise jobs queued so
1879
2230
  // far have run, and keyed by cast ID. Nothing is cached: each batch is read once and dropped.
1880
2231
  const referenceBatches = new WeakMap();
@@ -1963,7 +2314,7 @@ const resolveById = async (type, args, context, requiredId) => {
1963
2314
  const queryArgs = { id: { operator: 'EQ', value: args.id } };
1964
2315
  await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
1965
2316
  assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
1966
- assertSupportedOperators(type.gqltype, queryArgs, 'find');
2317
+ assertQueryConditions(type.gqltype, queryArgs, 'find');
1967
2318
  const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
1968
2319
  return results[0] || null;
1969
2320
  };
@@ -2284,6 +2635,33 @@ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
2284
2635
  return `${type.gqltype.name} has no writable fields${state}${id}`;
2285
2636
  };
2286
2637
 
2638
+ const createArgsForRegisteredOperation = (inputModel) => {
2639
+ if (!inputModel) return null;
2640
+ return { input: { type: new GraphQLNonNull(inputModel) } };
2641
+ };
2642
+
2643
+ const buildRegisteredOperationField = (entry, registration, operation) => {
2644
+ const field = {
2645
+ type: registration.outputModel,
2646
+ description: registration.description,
2647
+ args: createArgsForRegisteredOperation(registration.inputModel),
2648
+ async resolve(parent, args, context) {
2649
+ const params = {
2650
+ args,
2651
+ operation,
2652
+ entry,
2653
+ context,
2654
+ };
2655
+ await executeMiddleware(params);
2656
+ return executeRegisteredOperation(args.input, registration, operation, context);
2657
+ },
2658
+ };
2659
+ if (operation === operations.CUSTOM_MUTATION) {
2660
+ field.extensions = { simfinityMutation: { operation } };
2661
+ }
2662
+ return field;
2663
+ };
2664
+
2287
2665
  // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
2288
2666
  // a state-machine action, the action and its from/to states), so tools such as the MCP package can
2289
2667
  // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
@@ -2419,32 +2797,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2419
2797
 
2420
2798
  for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
2421
2799
  if (!shouldNotBeIncludedInSchema(includedCustomMutations, entry)) {
2422
- const argsObject = registeredMutation.inputModel
2423
- ? { input: { type: new GraphQLNonNull(registeredMutation.inputModel) } } : null;
2424
- rootQueryArgs.fields[entry] = {
2425
- type: registeredMutation.outputModel,
2426
- description: registeredMutation.description,
2427
- args: argsObject,
2428
- extensions: { simfinityMutation: { operation: operations.CUSTOM_MUTATION } },
2429
- async resolve(parent, args, context) {
2430
- const params = {
2431
- args,
2432
- operation: operations.CUSTOM_MUTATION,
2433
- entry,
2434
- context,
2435
- };
2436
- await executeMiddleware(params);
2437
- return executeRegisteredMutation(args.input, registeredMutation.callback,
2438
- context, null, registeredMutation.inputModel);
2439
- },
2440
- };
2800
+ rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
2801
+ operations.CUSTOM_MUTATION);
2441
2802
  }
2442
2803
  }
2443
2804
 
2444
2805
  return new GraphQLObjectType(rootQueryArgs);
2445
2806
  };
2446
2807
 
2447
- const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
2808
+ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
2448
2809
  const rootQueryArgs = {};
2449
2810
  rootQueryArgs.name = name;
2450
2811
  rootQueryArgs.fields = {};
@@ -2471,7 +2832,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2471
2832
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
2472
2833
  async resolve(parent, args, context, info) {
2473
2834
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
2474
- assertSupportedOperators(type.gqltype, args, 'find');
2835
+ const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'find'));
2475
2836
  const params = {
2476
2837
  type,
2477
2838
  args,
@@ -2481,8 +2842,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2481
2842
  await executeMiddleware(params);
2482
2843
  await executeScope(params);
2483
2844
  await applyJoinedScopes(joinedScopes, params);
2484
- assertStoredQueryPaths(type.gqltype, params.args, 'find');
2485
- assertSupportedOperators(type.gqltype, params.args, 'find');
2845
+ const filling = settleClientArgs(type.gqltype, params, 'find', issued);
2846
+ if (filling) await filling;
2486
2847
  const queryArgs = params.args;
2487
2848
  const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2488
2849
  && canReportCount(parent, info, context);
@@ -2519,7 +2880,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2519
2880
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
2520
2881
  async resolve(parent, args, context) {
2521
2882
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
2522
- assertSupportedOperators(type.gqltype, args, 'aggregate');
2883
+ const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'aggregate'));
2523
2884
  const params = {
2524
2885
  type,
2525
2886
  args,
@@ -2529,8 +2890,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2529
2890
  await executeMiddleware(params);
2530
2891
  await executeScope(params);
2531
2892
  await applyJoinedScopes(joinedScopes, params);
2532
- assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
2533
- assertSupportedOperators(type.gqltype, params.args, 'aggregate');
2893
+ const filling = settleClientArgs(type.gqltype, params, 'aggregate', issued);
2894
+ if (filling) await filling;
2534
2895
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2535
2896
  },
2536
2897
  };
@@ -2538,6 +2899,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2538
2899
  }
2539
2900
  }
2540
2901
 
2902
+ for (const [entry, registeredQuery] of Object.entries(registeredQueries)) {
2903
+ if (!shouldNotBeIncludedInSchema(includedCustomQueries, entry)) {
2904
+ rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredQuery,
2905
+ operations.CUSTOM_QUERY);
2906
+ }
2907
+ }
2908
+
2541
2909
  return new GraphQLObjectType(rootQueryArgs);
2542
2910
  };
2543
2911
 
@@ -2813,22 +3181,66 @@ const warnUndeclaredVirtualFields = () => {
2813
3181
 
2814
3182
  const getRegistrations = () => Object.values(typesDict.types);
2815
3183
 
3184
+ const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
3185
+ .filter(([entry]) => !shouldNotBeIncludedInSchema(includedOperations, entry))
3186
+ .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
3187
+
2816
3188
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
2817
- // endpoint types of its queries and mutations, and the input and output types of its registered
2818
- // mutations.
2819
- const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => [
3189
+ // endpoint types of its queries and mutations, and the input and output types of its custom operations.
3190
+ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
2820
3191
  ...Object.values(typesDict.types)
2821
3192
  .filter((type) => type.endpoint
2822
3193
  && ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
2823
3194
  || !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
2824
3195
  .map((type) => type.gqltype),
2825
- ...Object.entries(registeredMutations)
2826
- .filter(([entry]) => !shouldNotBeIncludedInSchema(includedCustomMutations, entry))
2827
- .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
3196
+ ...includedOperationTypes(registeredMutations, includedCustomMutations),
3197
+ ...includedOperationTypes(registeredQueries, includedCustomQueries),
2828
3198
  ];
2829
3199
 
2830
- const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
3200
+ const queryNameConflict = (name) => new SimfinityError(
3201
+ `Query ${name} is already registered or conflicts with a generated query in this schema`,
3202
+ 'QUERY_NAME_CONFLICT', 500,
3203
+ );
3204
+
3205
+ const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
3206
+ for (const type of Object.values(typesDict.types)) {
3207
+ if (!type.simpleEntityEndpointName || shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype)) continue;
3208
+ for (const name of [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]) {
3209
+ if (Object.hasOwn(registeredQueries, name) && !shouldNotBeIncludedInSchema(includedCustomQueries, name)) {
3210
+ throw queryNameConflict(name);
3211
+ }
3212
+ }
3213
+ }
3214
+ };
3215
+
3216
+ const invalidSchemaOptions = (detail) => new SimfinityError(
3217
+ `createSchema options ${detail}`, 'INVALID_SCHEMA_OPTIONS', 500,
3218
+ );
3219
+
3220
+ const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries']);
3221
+
3222
+ // The fourth createSchema() argument: omitted, or a plain object with known keys only.
3223
+ const readSchemaOptions = (options) => {
3224
+ if (options === undefined) return {};
3225
+ if (options === null || typeof options !== 'object' || !isPlainObject(options)) {
3226
+ throw invalidSchemaOptions('must be a plain object');
3227
+ }
3228
+ const unknown = Reflect.ownKeys(options).find((key) => !SCHEMA_OPTION_KEYS.has(key));
3229
+ if (unknown !== undefined) {
3230
+ throw invalidSchemaOptions(`has an unknown option ${String(unknown)}`);
3231
+ }
3232
+ const { includedCustomQueries } = options;
3233
+ if (includedCustomQueries != null && (!Array.isArray(includedCustomQueries)
3234
+ || includedCustomQueries.some((name) => typeof name !== 'string'))) {
3235
+ throw invalidSchemaOptions('includedCustomQueries must be an array of strings, null or undefined');
3236
+ }
3237
+ return { includedCustomQueries };
3238
+ };
3239
+
3240
+ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
2831
3241
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
3242
+ const { includedCustomQueries } = readSchemaOptions(options);
3243
+ assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
2832
3244
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
2833
3245
  const schemaTypes = collectSchemaOutputTypes();
2834
3246
  schemaTypes.forEach(assertNotBoundElsewhere);
@@ -2841,7 +3253,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2841
3253
  // only generated types or the fields of an input object reach is found when the schema is
2842
3254
  // constructed.
2843
3255
  assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
2844
- includedCustomMutations));
3256
+ includedCustomMutations, includedCustomQueries));
2845
3257
  const referencedTypes = markReferencedTypesForModelGeneration();
2846
3258
  // Entities, which keep their own stored identity: connect() types and relation targets.
2847
3259
  Object.values(typesDict.types).forEach((typeInfo) => {
@@ -2889,7 +3301,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2889
3301
  const objectMemberTypes = new Set();
2890
3302
  Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
2891
3303
 
2892
- let query = buildRootQuery('RootQueryType', includedQueryTypes);
3304
+ let query = buildRootQuery('RootQueryType', includedQueryTypes, undefined, includedCustomQueries);
2893
3305
  Object.values(typesDict.types).forEach(({ gqltype }) => {
2894
3306
  if (gqltype && !applicationResolvedFields.has(gqltype)) {
2895
3307
  applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
@@ -2900,12 +3312,13 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2900
3312
  // other type named JSON still collides, and GraphQLSchema reports the duplicate name.
2901
3313
  const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
2902
3314
  if (applicationJSON instanceof GraphQLScalarType) {
2903
- query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON));
3315
+ query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
3316
+ includedCustomQueries);
2904
3317
  }
2905
3318
 
2906
3319
  const schema = constructSchema(query, mutation);
2907
3320
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
2908
- // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
3321
+ // custom operation result, so another runtime cannot later generate resolvers this schema executes.
2909
3322
  schemaTypes.forEach((type) => {
2910
3323
  if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
2911
3324
  runtimeBoundTypes.set(type, runtimeIdentity);
@@ -2935,6 +3348,43 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
2935
3348
  };
2936
3349
  };
2937
3350
 
3351
+ const invalidQueryRegistration = (detail) => new SimfinityError(
3352
+ `registerCustomQuery ${detail}`, 'INVALID_QUERY_REGISTRATION', 500,
3353
+ );
3354
+
3355
+ const validateQueryRegistration = (name, registration) => {
3356
+ const { description, inputModel, outputModel, callback } = registration;
3357
+ if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
3358
+ throw invalidQueryRegistration('requires a valid GraphQL field name that does not start with __');
3359
+ }
3360
+ if (description != null && typeof description !== 'string') {
3361
+ throw invalidQueryRegistration('description must be a string');
3362
+ }
3363
+ if (inputModel != null && !(inputModel instanceof GraphQLInputObjectType)) {
3364
+ throw invalidQueryRegistration('input must be a GraphQLInputObjectType, null or undefined');
3365
+ }
3366
+ if (!isOutputType(outputModel)) {
3367
+ throw invalidQueryRegistration('output must be a GraphQL output type');
3368
+ }
3369
+ if (typeof callback !== 'function') {
3370
+ throw invalidQueryRegistration('callback must be a function');
3371
+ }
3372
+ };
3373
+
3374
+ const registerCustomQuery = (name, description, inputModel, outputModel, callback) => {
3375
+ const registration = {
3376
+ description,
3377
+ inputModel,
3378
+ outputModel,
3379
+ callback,
3380
+ };
3381
+ validateQueryRegistration(name, registration);
3382
+ if (Object.hasOwn(registeredQueries, name)) {
3383
+ throw queryNameConflict(name);
3384
+ }
3385
+ registeredQueries[name] = registration;
3386
+ };
3387
+
2938
3388
  // Gives the field a new extensions object as well as the resolver, so a copy made before keeps
2939
3389
  // untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
2940
3390
  // copying it, when a type is rebuilt from toConfig().
@@ -2980,13 +3430,13 @@ const autoGenerateResolvers = (gqltype) => {
2980
3430
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2981
3431
  }
2982
3432
  const joinedScopes = inspectClientPaths(relatedType, args, 'find');
2983
- assertSupportedOperators(relatedType, args, 'find');
3433
+ const issued = markClientConstructs(assertQueryConditions(relatedType, args, 'find'));
2984
3434
  const params = { type: relatedTypeInfo, args, operation: 'find', context };
2985
3435
  await executeMiddleware(params);
2986
3436
  await executeScope(params);
2987
3437
  await applyJoinedScopes(joinedScopes, params);
2988
- assertStoredQueryPaths(relatedType, params.args, 'find');
2989
- assertSupportedOperators(relatedType, params.args, 'find');
3438
+ const filling = settleClientArgs(relatedType, params, 'find', issued);
3439
+ if (filling) await filling;
2990
3440
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
2991
3441
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
2992
3442
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
@@ -3171,6 +3621,7 @@ function formatArgs(argsArray) {
3171
3621
  getRegistrations,
3172
3622
  use,
3173
3623
  registerMutation,
3624
+ registerCustomQuery,
3174
3625
  saveObject,
3175
3626
  preventCreatingCollection,
3176
3627
  };