@simtlix/simfinity-core 3.9.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';
@@ -17,6 +17,7 @@ import {
17
17
  collectQueryOperators, collectQueryPathEntries, collectQueryPaths, walkQueryPath,
18
18
  } from './query-plan.js';
19
19
  import { QUERY_OPERATORS, isQueryOperator, resolveQueryFeatures } from './query-operators.js';
20
+ import { assertEmptinessPath, describeEmptiness, isEmptinessOperator } from './query-emptiness.js';
20
21
  import { relationFieldOwners } from './relation-owners.js';
21
22
  import { isThenable } from './auth/thenable.js';
22
23
  import {
@@ -178,7 +179,10 @@ const prefixScopeArgs = (scopeArgs, prefix, typeName) => {
178
179
  throw new SimfinityError(`Scope filter ${typeName}.${name} requires non-empty terms`, 'MISSING_FILTER_PATH', 400);
179
180
  }
180
181
  for (const term of value.terms) {
181
- 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 });
182
186
  }
183
187
  } else conditions.push({ field: `${prefix}.${name}`, operator: value.operator, value: value.value });
184
188
  }
@@ -272,13 +276,22 @@ const assertFieldMetadataNamesFree = (roots) => {
272
276
  if (clash) throw reservedTypeNameError(clash);
273
277
  };
274
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
+
275
287
  // Constructs the schema of these roots. When one of its types has the name of a field metadata type,
276
288
  // those are renamed SimfinityFieldExtensionsType and SimfinityRelationType, with a number appended
277
289
  // when the schema also has those names, in every schema the process constructs from then on. A
278
290
  // construction that fails puts the previous metadata types back, so only a schema that exists fixes
279
291
  // the names.
280
292
  const constructSchema = (query, mutation) => {
281
- const roots = [query, mutation];
293
+ const config = buildSchemaConfig(query, mutation);
294
+ const roots = Object.values(config);
282
295
  const clash = fieldMetadataClash(roots);
283
296
  if (clash && fieldMetadataNamesFixed()) throw reservedTypeNameError(clash);
284
297
  let names = null;
@@ -293,7 +306,7 @@ const constructSchema = (query, mutation) => {
293
306
  const restore = names ? renameFieldMetadataTypes(...names) : null;
294
307
  let schema;
295
308
  try {
296
- schema = new GraphQLSchema({ query, mutation });
309
+ schema = new GraphQLSchema(config);
297
310
  } catch (error) {
298
311
  if (restore) restore();
299
312
  throw error;
@@ -473,6 +486,7 @@ export const createRuntime = (adapter) => {
473
486
  const selfCollectionNameHolders = new Map();
474
487
  const qualifiedSelfCollections = new Map();
475
488
  const registeredMutations = {};
489
+ const registeredQueries = {};
476
490
  const middlewares = [];
477
491
 
478
492
  const operations = {
@@ -481,6 +495,7 @@ export const createRuntime = (adapter) => {
481
495
  DELETE: 'delete',
482
496
  STATE_CHANGED: 'state_changed',
483
497
  CUSTOM_MUTATION: 'custom_mutation',
498
+ CUSTOM_QUERY: 'custom_query',
484
499
  };
485
500
 
486
501
  const use = (middleware) => {
@@ -533,7 +548,7 @@ export const createRuntime = (adapter) => {
533
548
  };
534
549
 
535
550
  // 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.
551
+ // custom operation results, following the same references as GraphQLSchema.
537
552
  const collectSchemaOutputTypes = () => {
538
553
  const visited = new Set();
539
554
  const outputTypes = [];
@@ -550,8 +565,13 @@ export const createRuntime = (adapter) => {
550
565
  named.getInterfaces().forEach(visit);
551
566
  for (const field of Object.values(named.getFields())) visit(field.type);
552
567
  };
553
- for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
554
- 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);
555
575
  return outputTypes;
556
576
  };
557
577
 
@@ -1274,10 +1294,15 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1274
1294
  return { modelArgs, collectionFields };
1275
1295
  };
1276
1296
 
1277
- 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
+ }
1278
1303
  const nextInput = attemptInputs(args, inputModel);
1279
1304
  return adapter.withTransaction(
1280
- session,
1305
+ null,
1281
1306
  (mySession) => callback(nextInput(), mySession, context),
1282
1307
  );
1283
1308
  };
@@ -1741,12 +1766,15 @@ const copyFilterGroups = (groups) => (Array.isArray(groups) ? groups.map((group)
1741
1766
  // client AND/OR groups whose conditions use each path, or `topLevel` for every other form.
1742
1767
  // graphql-js passes the same variable values to every resolver call of a request, so the client's
1743
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.
1744
1772
  const inspectClientPaths = (gqltype, args, operation) => {
1745
1773
  for (const key of ['AND', 'OR']) {
1746
1774
  if (Object.hasOwn(args, key)) args[key] = copyFilterGroups(args[key]);
1747
1775
  }
1748
1776
  const joinedScopes = new Map();
1749
- for (const { segments, group } of collectQueryPathEntries(args, operation)) {
1777
+ for (const { segments, group, member } of collectQueryPathEntries(args, operation, { gqltype, expandMembers: true })) {
1750
1778
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1751
1779
  const path = segments.slice(0, index + 1).join('.');
1752
1780
  if (isVirtualField(field)) {
@@ -1756,6 +1784,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
1756
1784
  if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
1757
1785
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
1758
1786
  }
1787
+ if (member) return;
1759
1788
  const relation = field.extensions?.relation;
1760
1789
  if (!relation || relation.embedded) return;
1761
1790
  const target = typesDict.types[unwrapListAndNonNull(field.type).name];
@@ -1794,19 +1823,103 @@ const assertStoredQueryPaths = (gqltype, args, operation) => {
1794
1823
  }
1795
1824
  };
1796
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
+
1797
1870
  // 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);
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;
1808
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 }));
1809
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);
1810
1923
  };
1811
1924
 
1812
1925
  // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
@@ -1867,6 +1980,7 @@ const applyJoinedScopes = async (joinedScopes, params) => {
1867
1980
  if (!groups.length) continue;
1868
1981
  if (collection) throw forbiddenPath(`Query path ${prefix} enters the scoped collection ${collection}`);
1869
1982
  assertJoinedScopePaths(groups, prefix, target);
1983
+ assertJoinedScopeConditions(params.type.gqltype, scopeArgs, groups, prefix, target);
1870
1984
  queryGroups ??= collectFilterGroups(params.args);
1871
1985
  const addedDepth = 1 + Math.max(...groups.map(groupDepth));
1872
1986
  if (topLevel || [...owners].some((owner) => !queryGroups.has(owner)
@@ -1963,7 +2077,7 @@ const resolveById = async (type, args, context, requiredId) => {
1963
2077
  const queryArgs = { id: { operator: 'EQ', value: args.id } };
1964
2078
  await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
1965
2079
  assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
1966
- assertSupportedOperators(type.gqltype, queryArgs, 'find');
2080
+ assertQueryConditions(type.gqltype, queryArgs, 'find');
1967
2081
  const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
1968
2082
  return results[0] || null;
1969
2083
  };
@@ -2284,6 +2398,33 @@ const withoutWritableFields = (type, { noWritableId = false } = {}) => {
2284
2398
  return `${type.gqltype.name} has no writable fields${state}${id}`;
2285
2399
  };
2286
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
+
2287
2428
  // Every generated mutation field carries extensions.simfinityMutation with its operation (and, for
2288
2429
  // a state-machine action, the action and its from/to states), so tools such as the MCP package can
2289
2430
  // classify it without guessing from its name. Extensions do not appear in SDL or introspection.
@@ -2419,32 +2560,15 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2419
2560
 
2420
2561
  for (const [entry, registeredMutation] of Object.entries(registeredMutations)) {
2421
2562
  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
- };
2563
+ rootQueryArgs.fields[entry] = buildRegisteredOperationField(entry, registeredMutation,
2564
+ operations.CUSTOM_MUTATION);
2441
2565
  }
2442
2566
  }
2443
2567
 
2444
2568
  return new GraphQLObjectType(rootQueryArgs);
2445
2569
  };
2446
2570
 
2447
- const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult) => {
2571
+ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
2448
2572
  const rootQueryArgs = {};
2449
2573
  rootQueryArgs.name = name;
2450
2574
  rootQueryArgs.fields = {};
@@ -2471,7 +2595,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2471
2595
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
2472
2596
  async resolve(parent, args, context, info) {
2473
2597
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
2474
- assertSupportedOperators(type.gqltype, args, 'find');
2598
+ assertQueryConditions(type.gqltype, args, 'find');
2475
2599
  const params = {
2476
2600
  type,
2477
2601
  args,
@@ -2482,7 +2606,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2482
2606
  await executeScope(params);
2483
2607
  await applyJoinedScopes(joinedScopes, params);
2484
2608
  assertStoredQueryPaths(type.gqltype, params.args, 'find');
2485
- assertSupportedOperators(type.gqltype, params.args, 'find');
2609
+ assertQueryConditions(type.gqltype, params.args, 'find');
2486
2610
  const queryArgs = params.args;
2487
2611
  const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2488
2612
  && canReportCount(parent, info, context);
@@ -2519,7 +2643,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2519
2643
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
2520
2644
  async resolve(parent, args, context) {
2521
2645
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
2522
- assertSupportedOperators(type.gqltype, args, 'aggregate');
2646
+ assertQueryConditions(type.gqltype, args, 'aggregate');
2523
2647
  const params = {
2524
2648
  type,
2525
2649
  args,
@@ -2530,7 +2654,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2530
2654
  await executeScope(params);
2531
2655
  await applyJoinedScopes(joinedScopes, params);
2532
2656
  assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
2533
- assertSupportedOperators(type.gqltype, params.args, 'aggregate');
2657
+ assertQueryConditions(type.gqltype, params.args, 'aggregate');
2534
2658
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2535
2659
  },
2536
2660
  };
@@ -2538,6 +2662,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2538
2662
  }
2539
2663
  }
2540
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
+
2541
2672
  return new GraphQLObjectType(rootQueryArgs);
2542
2673
  };
2543
2674
 
@@ -2813,22 +2944,66 @@ const warnUndeclaredVirtualFields = () => {
2813
2944
 
2814
2945
  const getRegistrations = () => Object.values(typesDict.types);
2815
2946
 
2947
+ const includedOperationTypes = (registrations, includedOperations) => Object.entries(registrations)
2948
+ .filter(([entry]) => !shouldNotBeIncludedInSchema(includedOperations, entry))
2949
+ .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean));
2950
+
2816
2951
  // 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) => [
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) => [
2820
2954
  ...Object.values(typesDict.types)
2821
2955
  .filter((type) => type.endpoint
2822
2956
  && ((type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
2823
2957
  || !shouldNotBeIncludedInSchema(includedMutationTypes, type.gqltype)))
2824
2958
  .map((type) => type.gqltype),
2825
- ...Object.entries(registeredMutations)
2826
- .filter(([entry]) => !shouldNotBeIncludedInSchema(includedCustomMutations, entry))
2827
- .flatMap(([, { inputModel, outputModel }]) => [inputModel, outputModel].filter(Boolean)),
2959
+ ...includedOperationTypes(registeredMutations, includedCustomMutations),
2960
+ ...includedOperationTypes(registeredQueries, includedCustomQueries),
2828
2961
  ];
2829
2962
 
2830
- 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) => {
2831
3004
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
3005
+ const { includedCustomQueries } = readSchemaOptions(options);
3006
+ assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
2832
3007
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
2833
3008
  const schemaTypes = collectSchemaOutputTypes();
2834
3009
  schemaTypes.forEach(assertNotBoundElsewhere);
@@ -2841,7 +3016,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2841
3016
  // only generated types or the fields of an input object reach is found when the schema is
2842
3017
  // constructed.
2843
3018
  assertFieldMetadataNamesFree(includedApplicationTypes(includedQueryTypes, includedMutationTypes,
2844
- includedCustomMutations));
3019
+ includedCustomMutations, includedCustomQueries));
2845
3020
  const referencedTypes = markReferencedTypesForModelGeneration();
2846
3021
  // Entities, which keep their own stored identity: connect() types and relation targets.
2847
3022
  Object.values(typesDict.types).forEach((typeInfo) => {
@@ -2889,7 +3064,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2889
3064
  const objectMemberTypes = new Set();
2890
3065
  Object.values(typesDict.types).forEach(({ gqltype }) => installObjectMemberResolvers(gqltype, objectMemberTypes));
2891
3066
 
2892
- let query = buildRootQuery('RootQueryType', includedQueryTypes);
3067
+ let query = buildRootQuery('RootQueryType', includedQueryTypes, undefined, includedCustomQueries);
2893
3068
  Object.values(typesDict.types).forEach(({ gqltype }) => {
2894
3069
  if (gqltype && !applicationResolvedFields.has(gqltype)) {
2895
3070
  applicationResolvedFields.set(gqltype, listApplicationResolvedFields(gqltype));
@@ -2900,12 +3075,13 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2900
3075
  // other type named JSON still collides, and GraphQLSchema reports the duplicate name.
2901
3076
  const applicationJSON = findSchemaType([query, mutation], 'JSON', QLTypeAggregationResult);
2902
3077
  if (applicationJSON instanceof GraphQLScalarType) {
2903
- query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON));
3078
+ query = buildRootQuery('RootQueryType', includedQueryTypes, aggregationResultTypeFor(applicationJSON),
3079
+ includedCustomQueries);
2904
3080
  }
2905
3081
 
2906
3082
  const schema = constructSchema(query, mutation);
2907
3083
  // 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.
3084
+ // custom operation result, so another runtime cannot later generate resolvers this schema executes.
2909
3085
  schemaTypes.forEach((type) => {
2910
3086
  if (type instanceof GraphQLObjectType && hasUnresolvedRelation(type)) {
2911
3087
  runtimeBoundTypes.set(type, runtimeIdentity);
@@ -2935,6 +3111,43 @@ const registerMutation = (name, description, inputModel, outputModel, callback)
2935
3111
  };
2936
3112
  };
2937
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
+
2938
3151
  // Gives the field a new extensions object as well as the resolver, so a copy made before keeps
2939
3152
  // untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
2940
3153
  // copying it, when a type is rebuilt from toConfig().
@@ -2980,13 +3193,13 @@ const autoGenerateResolvers = (gqltype) => {
2980
3193
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2981
3194
  }
2982
3195
  const joinedScopes = inspectClientPaths(relatedType, args, 'find');
2983
- assertSupportedOperators(relatedType, args, 'find');
3196
+ assertQueryConditions(relatedType, args, 'find');
2984
3197
  const params = { type: relatedTypeInfo, args, operation: 'find', context };
2985
3198
  await executeMiddleware(params);
2986
3199
  await executeScope(params);
2987
3200
  await applyJoinedScopes(joinedScopes, params);
2988
3201
  assertStoredQueryPaths(relatedType, params.args, 'find');
2989
- assertSupportedOperators(relatedType, params.args, 'find');
3202
+ assertQueryConditions(relatedType, params.args, 'find');
2990
3203
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
2991
3204
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
2992
3205
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
@@ -3171,6 +3384,7 @@ function formatArgs(argsArray) {
3171
3384
  getRegistrations,
3172
3385
  use,
3173
3386
  registerMutation,
3387
+ registerCustomQuery,
3174
3388
  saveObject,
3175
3389
  preventCreatingCollection,
3176
3390
  };