@simtlix/simfinity-core 3.8.0 → 3.10.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
@@ -4,7 +4,7 @@ import {
4
4
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
5
5
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
6
6
  GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
7
- Kind, getNamedType,
7
+ Kind, getNamedType, isObjectType, isOutputType,
8
8
  } from 'graphql';
9
9
 
10
10
  import SimfinityError from './errors/simfinity.error.js';
@@ -13,7 +13,11 @@ import { ForbiddenError } from './auth/errors.js';
13
13
  import QLOperator from './const/QLOperator.js';
14
14
  import QLValue from './const/QLValue.js';
15
15
  import QLSort from './const/QLSort.js';
16
- import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './query-plan.js';
16
+ import {
17
+ collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
18
+ } from './query-plan.js';
19
+ import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
20
+ import { assertEmptinessPath, describeEmptiness, isEmptinessOperator } from './query-emptiness.js';
17
21
  import { relationFieldOwners } from './relation-owners.js';
18
22
  import { isThenable } from './auth/thenable.js';
19
23
  import {
@@ -175,7 +179,10 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
175
179
  throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
176
180
  }
177
181
  for (const term of value.terms) {
178
- conditions.push({ field: `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
182
+ // An emptiness term without a path (omitted, null or "") tests the field itself; other
183
+ // operators keep the path as written.
184
+ const itself = isEmptinessOperator(term?.operator) && (term.path == null || term.path === '');
185
+ conditions.push({ field: itself ? `${prefix}.${name}` : `${prefix}.${name}.${term?.path}`, operator: term?.operator, value: term?.value });
179
186
  }
180
187
  } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
181
188
  }
@@ -269,13 +276,22 @@ const assertFieldMetadataNamesFree = (roots) => {
269
276
  if (clash) throw reservedTypeNameError(clash);
270
277
  };
271
278
 
279
+ const buildSchemaConfig = (query, mutation) => {
280
+ const config = { query };
281
+ if (Object.keys(mutation.getFields()).length > 0) {
282
+ config.mutation = mutation;
283
+ }
284
+ return config;
285
+ };
286
+
272
287
  // Constructs the schema of these roots. When one of its types has the name of a field metadata type,
273
288
  // those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
274
289
  // when the schema also has those names, in every schema the process constructs from then on. A
275
290
  // construction that fails puts the previous metadata types back, so only a schema that exists fixes
276
291
  // the names.
277
292
  const constructSchema = (query, mutation) => {
278
- const roots = [query, mutation];
293
+ const config = buildSchemaConfig(query, mutation);
294
+ const roots = Object.values(config);
279
295
  const clash = fieldMetadataClash(roots);
280
296
  if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
281
297
  let names = null;
@@ -290,7 +306,7 @@ const constructSchema = (query, mutation) => {
290
306
  const restore = names ? renameFieldMetadataTypes(...names) : null;
291
307
  let schema;
292
308
  try {
293
- schema = new GraphQLSchema({ query, mutation });
309
+ schema = new GraphQLSchema(config);
294
310
  } catch (error) {
295
311
  if (restore) restore();
296
312
  throw error;
@@ -451,6 +467,10 @@ export const createRuntime = (adapter) => {
451
467
  if (!adapter) {
452
468
  throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
453
469
  }
470
+ // The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
471
+ // operators when it declares none.
472
+ const supportedOperators = resolveQueryFeatures(adapter.queryFeatures).operators;
473
+ const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
454
474
 
455
475
  const stateValue = adapter.stateValue || ((state) => state.name);
456
476
  const runtimeIdentity = {};
@@ -466,6 +486,7 @@ export const createRuntime = (adapter) => {
466
486
  const selfCollectionNameHolders = new Map();
467
487
  const qualifiedSelfCollections = new Map();
468
488
  const registeredMutations = {};
489
+ const registeredQueries = {};
469
490
  const middlewares = [];
470
491
 
471
492
  const operations = {
@@ -474,6 +495,7 @@ export const createRuntime = (adapter) => {
474
495
  DELETE: 'delete',
475
496
  STATE_CHANGED: 'state_changed',
476
497
  CUSTOM_MUTATION: 'custom_mutation',
498
+ CUSTOM_QUERY: 'custom_query',
477
499
  };
478
500
 
479
501
  const use = (middleware) => {
@@ -526,7 +548,7 @@ export const createRuntime = (adapter) => {
526
548
  };
527
549
 
528
550
  // Object and interface types a schema of this runtime can reach from its registered types and
529
- // custom mutation results, following the same references as GraphQLSchema.
551
+ // custom operation results, following the same references as GraphQLSchema.
530
552
  const collectSchemaOutputTypes = () => {
531
553
  const visited = new Set();
532
554
  const outputTypes = [];
@@ -543,8 +565,13 @@ export const createRuntime = (adapter) => {
543
565
  named.getInterfaces().forEach(visit);
544
566
  for (const field of Object.values(named.getFields())) visit(field.type);
545
567
  };
546
- for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
547
- for (const { outputModel } of Object.values(registeredMutations)) visit(outputModel);
568
+ const registeredTypes = Object.values(typesDict.types).map(({ gqltype }) => gqltype);
569
+ const customOperations = [
570
+ ...Object.values(registeredMutations),
571
+ ...Object.values(registeredQueries),
572
+ ];
573
+ const customOutputTypes = customOperations.map(({ outputModel }) => outputModel);
574
+ [...registeredTypes, ...customOutputTypes].forEach(visit);
548
575
  return outputTypes;
549
576
  };
550
577
 
@@ -1267,10 +1294,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1267
1294
  return { modelArgs, collectionFields };
1268
1295
  };
1269
1296
 
1270
- const executeRegisteredMutation = (args, callback, context, session, inputModel) => {
1297
+ // Custom queries run without a transaction or session; custom mutations run in one, which may retry.
1298
+ const executeRegisteredOperation = (args, registration, operation, context) => {
1299
+ const { inputModel, callback } = registration;
1300
+ if (operation === operations.CUSTOM_QUERY) {
1301
+ return callback(cloneInput(args, inputModel), context);
1302
+ }
1271
1303
  const nextInput = attemptInputs(args, inputModel);
1272
1304
  return adapter.withTransaction(
1273
- session,
1305
+ null,
1274
1306
  (mySession) => callback(nextInput(), mySession, context),
1275
1307
  );
1276
1308
  };
@@ -1734,12 +1766,15 @@ const copyFilterGroups = (groups) => (Array.isArray(groups) ? groups.map((group)
1734
1766
  // client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
1735
1767
  // graphql-js passes the same variable values to every resolver call of a request, so the client's
1736
1768
  // AND/OR groups are first copied onto this call's args, and joined scopes are only placed in those copies.
1769
+ // An emptiness condition on an embedded object reads its stored members, so their paths are checked
1770
+ // as client paths too (#108 part 2a); a member path only gets the 403 checks, and enters no joined
1771
+ // scope.
1737
1772
  const inspectClientPaths = (gqltype, args, operation) => {
1738
1773
  for (const key of ['AND', 'OR']) {
1739
1774
  if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
1740
1775
  }
1741
1776
  const joinedScopes = new Map();
1742
- for (const { segments, group } of collectQueryPathEntries(args, operation)) {
1777
+ for (const { segments, group, member } of collectQueryPathEntries(args, operation, { gqltype, expandMembers: true })) {
1743
1778
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1744
1779
  const path = segments.slice(0, index + 1).join('.');
1745
1780
  if (isVirtualField(field)) {
@@ -1749,6 +1784,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
1749
1784
  if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
1750
1785
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
1751
1786
  }
1787
+ if (member) return;
1752
1788
  const relation = field.extensions?.relation;
1753
1789
  if (!relation || relation.embedded) return;
1754
1790
  const target = typesDict.types[unwrapListAndNonNull(field.type).name];
@@ -1787,6 +1823,105 @@ const assertStoredQueryPaths = (gqltype, args, operation) => {
1787
1823
  }
1788
1824
  };
1789
1825
 
1826
+ const assertDeclaredOperator = (gqltype, operator, path) => {
1827
+ if (!supportedOperators.has(operator)) {
1828
+ throw new SimfinityError(`The database adapter does not support the ${operator} filter operator, used on `
1829
+ + `${gqltype.name}.${path}`, 'UNSUPPORTED_QUERY', 400);
1830
+ }
1831
+ };
1832
+
1833
+ // The checks of an emptiness condition (#108 part 2a) that come before rules 3 to 6 of
1834
+ // describeEmptiness, with the codes and messages of createQueryPlan: the group depth, a dotted field
1835
+ // next to a path, an emptiness operator on an object filter without terms or beside terms, the
1836
+ // fields of the path (rule 1), then the adapter's operators (rule 2). Returns false when the backends
1837
+ // never read the condition as one, so that rules 3 to 6 do not apply: a term of a field that is not
1838
+ // an object (they read that filter's own operator) and a condition without a string field (they
1839
+ // reject it their own way; when the adapter does not declare the operator, no compiler sees it, so
1840
+ // the gate gives createQueryPlan's rule-1 code).
1841
+ const assertEmptinessForm = (gqltype, {
1842
+ operator, path, segments, form, field, depth, ambiguous,
1843
+ }) => {
1844
+ if (form === 'condition') {
1845
+ if (depth > MAX_FILTER_GROUP_DEPTH) throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
1846
+ if (typeof field !== 'string') {
1847
+ if (!supportedOperators.has(operator)) throw new SimfinityError('Invalid filter path', 'INVALID_FILTER_PATH', 400);
1848
+ return false;
1849
+ }
1850
+ if (ambiguous) {
1851
+ throw new SimfinityError('Filter condition cannot use both dotted field syntax and a separate path', 'AMBIGUOUS_FILTER_FIELD', 400);
1852
+ }
1853
+ } else {
1854
+ assertEmptinessPath(gqltype, [field]);
1855
+ const type = getNamedType(gqltype.getFields()[field].type);
1856
+ if (isObjectType(type) && form !== 'term') {
1857
+ throw new SimfinityError(`Filter on ${field} takes ${operator} only in a term; to test the field itself, use a term `
1858
+ + `without a path: ${field}: { terms: [{ operator: ${operator} }] }`, 'MISSING_FILTER_PATH', 400);
1859
+ }
1860
+ if (form === 'term' && !isObjectType(type) && !(type instanceof GraphQLInterfaceType) && !(type instanceof GraphQLUnionType)) {
1861
+ assertDeclaredOperator(gqltype, operator, path);
1862
+ return false;
1863
+ }
1864
+ }
1865
+ assertEmptinessPath(gqltype, segments, { path });
1866
+ assertDeclaredOperator(gqltype, operator, path);
1867
+ return true;
1868
+ };
1869
+
1870
+ // Rejects a QLOperator value that the adapter does not declare in queryFeatures, with
1871
+ // UNSUPPORTED_QUERY (400), so an adapter never drops or misreads a condition it does not compile,
1872
+ // and applies to every IS_EMPTY or IS_NOT_EMPTY condition the order of checks of #108 part 2a,
1873
+ // whatever the adapter declares. Runs on the client's arguments before middleware and on the final
1874
+ // arguments before the adapter call, which covers what middleware, scopes and joined scopes added,
1875
+ // so that an emptiness condition gets the code that a direct createQueryPlan call gives, whoever
1876
+ // wrote it and whatever the caller may read. Operators that QLOperator does not list are left to the
1877
+ // adapter, which reports them as before.
1878
+ const assertQueryConditions = (gqltype, args, operation) => {
1879
+ for (const entry of collectQueryOperators(args, operation)) {
1880
+ const { operator, path } = entry;
1881
+ if (isEmptinessOperator(operator)) {
1882
+ if (assertEmptinessForm(gqltype, entry)) describeEmptiness(gqltype, entry.segments, { operator, value: entry.value, path });
1883
+ } else if (!supportsEveryOperator && isQueryOperator(operator)) assertDeclaredOperator(gqltype, operator, path);
1884
+ }
1885
+ };
1886
+
1887
+ // Decision 3 of #108 part 2a: a target find scope with an emptiness condition is not applied below a
1888
+ // client's relation path, because the prefix makes the condition cross a reference, which 2a
1889
+ // rejects. Runs after the scoped-collection and assertJoinedScopePaths 403s. The raw scope arguments,
1890
+ // operators beside terms included, first get the checks they get on the target's own endpoint (the
1891
+ // stored paths, then each emptiness condition's), so that a rejection the scope has there keeps its
1892
+ // code; otherwise the request fails with UNSUPPORTED_QUERY naming the scope and the prefixed condition.
1893
+ // A prefix that continues inside an embedded list or a collection (`olines.shop`, `reviews.item`)
1894
+ // adds more than a reference crossing. After its form and operator, each prefixed condition is checked
1895
+ // on `rootType`, the queried type, where rule 3, which comes before rules 4 to 6, rejects it with the
1896
+ // permanent INVALID_FILTER_PATH that the final gate and createQueryPlan give it wherever it is written.
1897
+ const assertJoinedScopeConditions = (rootType, scopeArgs, groups, prefix, target) => {
1898
+ const { gqltype } = target;
1899
+ const entries = collectQueryOperators(scopeArgs, 'find').filter(({ operator }) => isEmptinessOperator(operator));
1900
+ if (!entries.length) return;
1901
+ assertStoredQueryPaths(gqltype, scopeArgs, 'find');
1902
+ let throughList = false;
1903
+ walkQueryPath(rootType, prefix.split('.'), ({ field }) => { throughList ||= !!getListShape(field.type); });
1904
+ // Rule 6, here or on the target's own endpoint, is the rejection thrown below.
1905
+ const exceptRule6 = (check) => {
1906
+ try {
1907
+ check();
1908
+ } catch (error) {
1909
+ if (!(error instanceof SimfinityError) || error.getCode() !== 'UNSUPPORTED_QUERY') throw error;
1910
+ }
1911
+ };
1912
+ for (const entry of entries) {
1913
+ if (!assertEmptinessForm(gqltype, entry)) continue;
1914
+ const { operator, value } = entry;
1915
+ if (throughList) exceptRule6(() => describeEmptiness(rootType, `${prefix}.${entry.path}`, { operator, value }));
1916
+ exceptRule6(() => describeEmptiness(gqltype, entry.segments, { operator, value, path: entry.path }));
1917
+ }
1918
+ const prefixed = collectQueryOperators({ AND: groups }).find(({ operator }) => isEmptinessOperator(operator))
1919
+ ?? { operator: entries[0].operator, path: `${prefix}.${entries[0].path}` };
1920
+ throw new SimfinityError(`Query path ${prefix} enters ${gqltype.name}, whose find scope tests ${prefixed.path} with `
1921
+ + `${prefixed.operator}: emptiness through references and collections arrives with reference emptiness (#108); `
1922
+ + `until then, use EQ null in the ${gqltype.name} find scope`, 'UNSUPPORTED_QUERY', 400);
1923
+ };
1924
+
1790
1925
  // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
1791
1926
  // reach a non-embedded relation inside an embedded list, which is joined once per list item.
1792
1927
  const assertJoinedScopePaths = (groups, prefix, target) => {
@@ -1845,6 +1980,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
1845
1980
  if (!groups.length) continue;
1846
1981
  if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
1847
1982
  assertJoinedScopePaths(groups, prefix, target);
1983
+ assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
1848
1984
  queryGroups ??= collectFilterGroups(params.args);
1849
1985
  const addedDepth = 1 + Math.max(...groups.map(groupDepth));
1850
1986
  if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
@@ -1941,6 +2077,7 @@ const resolveById = async (type, args, context, requiredId) => {
1941
2077
  const queryArgs = { id: { operator: 'EQ', value: args.id } };
1942
2078
  await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
1943
2079
  assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
2080
+ assertQueryConditions(type.gqltype, queryArgs, 'find');
1944
2081
  const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
1945
2082
  return results[0] || null;
1946
2083
  };
@@ -2261,6 +2398,33 @@ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
2261
2398
  return `${type.gqltype.name} has no writable fields${state}${id}`;
2262
2399
  };
2263
2400
 
2401
+ const createArgsForRegisteredOperation = (inputModel) => {
2402
+ if (!inputModel) return null;
2403
+ return { input: { type: new GraphQLNonNull(inputModel) } };
2404
+ };
2405
+
2406
+ const buildRegisteredOperationField = (entry, registration, operation) => {
2407
+ const field = {
2408
+ type: registration.outputModel,
2409
+ description: registration.description,
2410
+ args: createArgsForRegisteredOperation(registration.inputModel),
2411
+ async resolve(parent, args, context) {
2412
+ const params = {
2413
+ args,
2414
+ operation,
2415
+ entry,
2416
+ context,
2417
+ };
2418
+ await executeMiddleware(params);
2419
+ return executeRegisteredOperation(args.input, registration, operation, context);
2420
+ },
2421
+ };
2422
+ if (operation === operations.CUSTOM_MUTATION) {
2423
+ field.extensions = { simfinityMutation: { operation } };
2424
+ }
2425
+ return field;
2426
+ };
2427
+
2264
2428
  // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
2265
2429
  // a state-machine action, the action and its from/to states), so tools such as the MCP package can
2266
2430
  // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
@@ -2396,32 +2560,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2396
2560
 
2397
2561
  for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
2398
2562
  if (!shouldNotBeIncludedInSchema(includedCustomMutations, entry)) {
2399
- const argsObject = registeredMutation.inputModel
2400
- ? { input: { type: new GraphQLNonNull(registeredMutation.inputModel) } } : null;
2401
- rootQueryArgs.fields[entry] = {
2402
- type: registeredMutation.outputModel,
2403
- description: registeredMutation.description,
2404
- args: argsObject,
2405
- extensions: { simfinityMutation: { operation: operations.CUSTOM_MUTATION } },
2406
- async resolve(parent, args, context) {
2407
- const params = {
2408
- args,
2409
- operation: operations.CUSTOM_MUTATION,
2410
- entry,
2411
- context,
2412
- };
2413
- await executeMiddleware(params);
2414
- return executeRegisteredMutation(args.input, registeredMutation.callback,
2415
- context, null, registeredMutation.inputModel);
2416
- },
2417
- };
2563
+ rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
2564
+ operations.CUSTOM_MUTATION);
2418
2565
  }
2419
2566
  }
2420
2567
 
2421
2568
  return new GraphQLObjectType(rootQueryArgs);
2422
2569
  };
2423
2570
 
2424
- const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
2571
+ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
2425
2572
  const rootQueryArgs = {};
2426
2573
  rootQueryArgs.name = name;
2427
2574
  rootQueryArgs.fields = {};
@@ -2448,6 +2595,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2448
2595
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
2449
2596
  async resolve(parent, args, context, info) {
2450
2597
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
2598
+ assertQueryConditions(type.gqltype, args, 'find');
2451
2599
  const params = {
2452
2600
  type,
2453
2601
  args,
@@ -2458,6 +2606,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2458
2606
  await executeScope(params);
2459
2607
  await applyJoinedScopes(joinedScopes, params);
2460
2608
  assertStoredQueryPaths(type.gqltype, params.args, 'find');
2609
+ assertQueryConditions(type.gqltype, params.args, 'find');
2461
2610
  const queryArgs = params.args;
2462
2611
  const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2463
2612
  && canReportCount(parent, info, context);
@@ -2494,6 +2643,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2494
2643
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
2495
2644
  async resolve(parent, args, context) {
2496
2645
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
2646
+ assertQueryConditions(type.gqltype, args, 'aggregate');
2497
2647
  const params = {
2498
2648
  type,
2499
2649
  args,
@@ -2504,6 +2654,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2504
2654
  await executeScope(params);
2505
2655
  await applyJoinedScopes(joinedScopes, params);
2506
2656
  assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
2657
+ assertQueryConditions(type.gqltype, params.args, 'aggregate');
2507
2658
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2508
2659
  },
2509
2660
  };
@@ -2511,6 +2662,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2511
2662
  }
2512
2663
  }
2513
2664
 
2665
+ for (const [entry, registeredQuery] of Object.entries(registeredQueries)) {
2666
+ if (!shouldNotBeIncludedInSchema(includedCustomQueries, entry)) {
2667
+ rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredQuery,
2668
+ operations.CUSTOM_QUERY);
2669
+ }
2670
+ }
2671
+
2514
2672
  return new GraphQLObjectType(rootQueryArgs);
2515
2673
  };
2516
2674
 
@@ -2786,22 +2944,66 @@ const warnUndeclaredVirtualFields = () => {
2786
2944
 
2787
2945
  const getRegistrations = () => Object.values(typesDict.types);
2788
2946
 
2947
+ const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
2948
+ .filter(([entry]) => !shouldNotBeIncludedInSchema(includedOperations, entry))
2949
+ .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
2950
+
2789
2951
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
2790
- // endpoint types of its queries and mutations, and the input and output types of its registered
2791
- // mutations.
2792
- const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => [
2952
+ // endpoint types of its queries and mutations, and the input and output types of its custom operations.
2953
+ const includedApplicationTypes = (includedQueryTypes, includedMutationTypes, includedCustomMutations, includedCustomQueries) => [
2793
2954
  ...Object.values(typesDict.types)
2794
2955
  .filter((type) => type.endpoint
2795
2956
  && ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
2796
2957
  || !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
2797
2958
  .map((type) => type.gqltype),
2798
- ...Object.entries(registeredMutations)
2799
- .filter(([entry]) => !shouldNotBeIncludedInSchema(includedCustomMutations, entry))
2800
- .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
2959
+ ...includedOperationTypes(registeredMutations, includedCustomMutations),
2960
+ ...includedOperationTypes(registeredQueries, includedCustomQueries),
2801
2961
  ];
2802
2962
 
2803
- const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations) => {
2963
+ const queryNameConflict = (name) => new SimfinityError(
2964
+ `Query ${name} is already registered or conflicts with a generated query in this schema`,
2965
+ 'QUERY_NAME_CONFLICT', 500,
2966
+ );
2967
+
2968
+ const assertQueryNamesAvailable = (includedQueryTypes, includedCustomQueries) => {
2969
+ for (const type of Object.values(typesDict.types)) {
2970
+ if (!type.simpleEntityEndpointName || shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype)) continue;
2971
+ for (const name of [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]) {
2972
+ if (Object.hasOwn(registeredQueries, name) && !shouldNotBeIncludedInSchema(includedCustomQueries, name)) {
2973
+ throw queryNameConflict(name);
2974
+ }
2975
+ }
2976
+ }
2977
+ };
2978
+
2979
+ const invalidSchemaOptions = (detail) => new SimfinityError(
2980
+ `createSchema options ${detail}`, 'INVALID_SCHEMA_OPTIONS', 500,
2981
+ );
2982
+
2983
+ const SCHEMA_OPTION_KEYS = new Set(['includedCustomQueries']);
2984
+
2985
+ // The fourth createSchema() argument: omitted, or a plain object with known keys only.
2986
+ const readSchemaOptions = (options) => {
2987
+ if (options === undefined) return {};
2988
+ if (options === null || typeof options !== 'object' || !isPlainObject(options)) {
2989
+ throw invalidSchemaOptions('must be a plain object');
2990
+ }
2991
+ const unknown = Reflect.ownKeys(options).find((key) => !SCHEMA_OPTION_KEYS.has(key));
2992
+ if (unknown !== undefined) {
2993
+ throw invalidSchemaOptions(`has an unknown option ${String(unknown)}`);
2994
+ }
2995
+ const { includedCustomQueries } = options;
2996
+ if (includedCustomQueries != null && (!Array.isArray(includedCustomQueries)
2997
+ || includedCustomQueries.some((name) => typeof name !== 'string'))) {
2998
+ throw invalidSchemaOptions('includedCustomQueries must be an array of strings, null or undefined');
2999
+ }
3000
+ return { includedCustomQueries };
3001
+ };
3002
+
3003
+ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomMutations, options) => {
2804
3004
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
3005
+ const { includedCustomQueries } = readSchemaOptions(options);
3006
+ assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
2805
3007
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
2806
3008
  const schemaTypes = collectSchemaOutputTypes();
2807
3009
  schemaTypes.forEach(assertNotBoundElsewhere);
@@ -2814,7 +3016,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2814
3016
  // only generated types or the fields of an input object reach is found when the schema is
2815
3017
  // constructed.
2816
3018
  assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
2817
- includedCustomMutations));
3019
+ includedCustomMutations, includedCustomQueries));
2818
3020
  const referencedTypes = markReferencedTypesForModelGeneration();
2819
3021
  // Entities, which keep their own stored identity: connect() types and relation targets.
2820
3022
  Object.values(typesDict.types).forEach((typeInfo) => {
@@ -2862,7 +3064,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2862
3064
  const objectMemberTypes = new Set();
2863
3065
  Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
2864
3066
 
2865
- let query = buildRootQuery('RootQueryType', includedQueryTypes);
3067
+ let query = buildRootQuery('RootQueryType', includedQueryTypes, undefined, includedCustomQueries);
2866
3068
  Object.values(typesDict.types).forEach(({ gqltype }) => {
2867
3069
  if (gqltype && !applicationResolvedFields.has(gqltype)) {
2868
3070
  applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
@@ -2873,12 +3075,13 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2873
3075
  // other type named JSON still collides, and GraphQLSchema reports the duplicate name.
2874
3076
  const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
2875
3077
  if (applicationJSON instanceof GraphQLScalarType) {
2876
- query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON));
3078
+ query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
3079
+ includedCustomQueries);
2877
3080
  }
2878
3081
 
2879
3082
  const schema = constructSchema(query, mutation);
2880
3083
  // Reserve reachable types whose relation fields still have no resolver, such as an unregistered
2881
- // custom mutation result, so another runtime cannot later generate resolvers this schema executes.
3084
+ // custom operation result, so another runtime cannot later generate resolvers this schema executes.
2882
3085
  schemaTypes.forEach((type) => {
2883
3086
  if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
2884
3087
  runtimeBoundTypes.set(type, runtimeIdentity);
@@ -2908,6 +3111,43 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
2908
3111
  };
2909
3112
  };
2910
3113
 
3114
+ const invalidQueryRegistration = (detail) => new SimfinityError(
3115
+ `registerCustomQuery ${detail}`, 'INVALID_QUERY_REGISTRATION', 500,
3116
+ );
3117
+
3118
+ const validateQueryRegistration = (name, registration) => {
3119
+ const { description, inputModel, outputModel, callback } = registration;
3120
+ if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
3121
+ throw invalidQueryRegistration('requires a valid GraphQL field name that does not start with __');
3122
+ }
3123
+ if (description != null && typeof description !== 'string') {
3124
+ throw invalidQueryRegistration('description must be a string');
3125
+ }
3126
+ if (inputModel != null && !(inputModel instanceof GraphQLInputObjectType)) {
3127
+ throw invalidQueryRegistration('input must be a GraphQLInputObjectType, null or undefined');
3128
+ }
3129
+ if (!isOutputType(outputModel)) {
3130
+ throw invalidQueryRegistration('output must be a GraphQL output type');
3131
+ }
3132
+ if (typeof callback !== 'function') {
3133
+ throw invalidQueryRegistration('callback must be a function');
3134
+ }
3135
+ };
3136
+
3137
+ const registerCustomQuery = (name, description, inputModel, outputModel, callback) => {
3138
+ const registration = {
3139
+ description,
3140
+ inputModel,
3141
+ outputModel,
3142
+ callback,
3143
+ };
3144
+ validateQueryRegistration(name, registration);
3145
+ if (Object.hasOwn(registeredQueries, name)) {
3146
+ throw queryNameConflict(name);
3147
+ }
3148
+ registeredQueries[name] = registration;
3149
+ };
3150
+
2911
3151
  // Gives the field a new extensions object as well as the resolver, so a copy made before keeps
2912
3152
  // untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
2913
3153
  // copying it, when a type is rebuilt from toConfig().
@@ -2953,11 +3193,13 @@ const autoGenerateResolvers = (gqltype) => {
2953
3193
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2954
3194
  }
2955
3195
  const joinedScopes = inspectClientPaths(relatedType, args, 'find');
3196
+ assertQueryConditions(relatedType, args, 'find');
2956
3197
  const params = { type: relatedTypeInfo, args, operation: 'find', context };
2957
3198
  await executeMiddleware(params);
2958
3199
  await executeScope(params);
2959
3200
  await applyJoinedScopes(joinedScopes, params);
2960
3201
  assertStoredQueryPaths(relatedType, params.args, 'find');
3202
+ assertQueryConditions(relatedType, params.args, 'find');
2961
3203
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
2962
3204
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
2963
3205
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
@@ -3142,6 +3384,7 @@ function formatArgs(argsArray) {
3142
3384
  getRegistrations,
3143
3385
  use,
3144
3386
  registerMutation,
3387
+ registerCustomQuery,
3145
3388
  saveObject,
3146
3389
  preventCreatingCollection,
3147
3390
  };