@simtlix/simfinity-core 3.11.0 → 3.12.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/README.md CHANGED
@@ -8,12 +8,13 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
8
8
 
9
9
  ## Runtime
10
10
 
11
- `createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
11
+ `createRuntime(adapter)` creates a Simfinity runtime. Each instance owns its registrations, generated input types, middleware, scopes, hooks, custom queries and mutations, views, and state machines; the GraphQL type objects it builds resolvers for are bound to it, and query and mutation limits are process-wide. It exposes:
12
12
 
13
13
  - `connect`, `addNoEndpointType`, and `createSchema` for registration and schema creation;
14
14
  - `getModel`, `getType`, `getInputType`, and `getRegistrations` for runtime inspection;
15
15
  - `use`, `registerMutation`, and `saveObject` for middleware and write orchestration;
16
16
  - `registerCustomQuery` for reads whose results are not stored records, such as previews, other sources or permission evaluations;
17
+ - `registerView` for a named list read of one registered type, with params and a fixed predicate, run through the type's scope;
17
18
  - `preventCreatingCollection` to pass validation-only storage setup to an adapter;
18
19
  - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size, reference-frame and nested-operation limits.
19
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/runtime.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  GraphQLObjectType, GraphQLString, GraphQLID, GraphQLSchema, GraphQLList,
7
7
  GraphQLNonNull, GraphQLInputObjectType, GraphQLScalarType,
8
8
  GraphQLInt, GraphQLEnumType, GraphQLBoolean, GraphQLError, GraphQLInterfaceType, GraphQLUnionType,
9
- Kind, getNamedType, isObjectType, isOutputType,
9
+ Kind, getNamedType, isEnumType, isListType, isNonNullType, isObjectType, isOutputType, isScalarType,
10
10
  } from 'graphql';
11
11
 
12
12
  import SimfinityError from './errors/simfinity.error.js';
@@ -495,6 +495,7 @@ export const createRuntime = (adapter) => {
495
495
  const qualifiedSelfCollections = new Map();
496
496
  const registeredMutations = {};
497
497
  const registeredQueries = {};
498
+ const registeredViews = {};
498
499
  const middlewares = [];
499
500
 
500
501
  const operations = {
@@ -1813,7 +1814,9 @@ const copyClientFilters = (args, operation) => {
1813
1814
  // scope. When the adapter compiles reference emptiness (#108 part 2b), a client's emptiness term or
1814
1815
  // condition reads its references and collections through scoped joins and frames instead, so it
1815
1816
  // enters no joined scope either; every other entry, beside it or on the same path, records its own.
1816
- const inspectClientPaths = (gqltype, args, operation) => {
1817
+ // Without `checkQueryable`, for a view's `where`, whose paths are the author's as a scope's are, a
1818
+ // non-queryable field is not rejected; a virtual one still is.
1819
+ const inspectClientPaths = (gqltype, args, operation, { checkQueryable = true } = {}) => {
1817
1820
  copyClientFilters(args, operation);
1818
1821
  const joinedScopes = new Map();
1819
1822
  const entries = collectQueryPathEntries(args, operation, { gqltype, expandMembers: true });
@@ -1827,7 +1830,7 @@ const inspectClientPaths = (gqltype, args, operation) => {
1827
1830
  throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
1828
1831
  }
1829
1832
  const queryable = field.extensions?.queryable;
1830
- if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
1833
+ if (checkQueryable && (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName)))) {
1831
1834
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
1832
1835
  }
1833
1836
  if (!joins) return;
@@ -2069,31 +2072,34 @@ const hasScopeMarker = (construct) => construct != null && Object.hasOwn(constru
2069
2072
 
2070
2073
  // Marks the client's constructs that read references or collections, from the gate's conditions on the
2071
2074
  // 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) => {
2075
+ // scoped joins and frames in these conditions, so every 2a code comes first. Returns the issued
2076
+ // markers, as id -> the client's operator and path, or null when the adapter does not compile
2077
+ // reference emptiness. Ids are ordinals in the order of the conditions, so identical arguments get
2078
+ // identical markers and sibling collection reads keep one batch key. With `issued`, the markers of
2079
+ // the call so far, the ids continue on that map, so that a view's `where`, marked after the client's
2080
+ // arguments, shares them; the limit still applies to each set of conditions on its own.
2081
+ const markClientConstructs = (checked, issued) => {
2077
2082
  if (!supportsReferenceEmptiness) return null;
2078
- const issued = new Map();
2083
+ const markers = issued ?? new Map();
2079
2084
  const pairs = new Set();
2080
2085
  for (const { entry, shape } of checked) {
2081
2086
  const { construct } = entry;
2082
2087
  if (!shape || !construct || hasScopeMarker(construct)) continue;
2083
2088
  const { joins, frames } = emptinessScopePaths(shape, entry.segments);
2084
2089
  if (!joins.length && !frames.length) continue;
2085
- const id = issued.size;
2090
+ const id = markers.size;
2086
2091
  construct[SCOPE_MARKER] = { id };
2087
- issued.set(id, { operator: entry.operator, path: entry.path });
2092
+ markers.set(id, { operator: entry.operator, path: entry.path });
2088
2093
  for (const path of joins) pairs.add(`join ${path}`);
2089
2094
  for (const path of frames) pairs.add(`frame ${path}`);
2090
2095
  }
2091
2096
  const limit = getQueryMaxReferenceFrames();
2092
2097
  if (pairs.size > limit) {
2093
2098
  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);
2099
+ + 'frames in one set of conditions (the client\'s arguments of a query field, or a view\'s where), more than the limit '
2100
+ + `of ${limit} (maxReferenceFrames)`, 'FILTER_DEPTH_EXCEEDED', 400);
2095
2101
  }
2096
- return issued;
2102
+ return markers;
2097
2103
  };
2098
2104
 
2099
2105
  // The detach check: every marker issued in this call sits, well formed (an object whose only own key
@@ -2805,6 +2811,170 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
2805
2811
  return new GraphQLObjectType(rootQueryArgs);
2806
2812
  };
2807
2813
 
2814
+ // The prefix every generated read shares, for the list and aggregate roots, the collection fields and
2815
+ // the views: the client's paths and conditions are inspected and marked, middleware runs, with `entry`
2816
+ // when one is given, `extend` adds the caller's own constructs and returns their joined scopes, the
2817
+ // type's scope runs, the client's joined scopes and then the extension's are applied, and the final
2818
+ // arguments are settled. Returns the params, whose args the adapter reads. An empty map of the
2819
+ // client's joined scopes is not awaited, so that, with the caller's await of this function, a read
2820
+ // without one still reaches the adapter after as many promise jobs as before it was shared.
2821
+ const prepareRead = async (type, args, context, operation, { entry, extend } = {}) => {
2822
+ const joinedScopes = inspectClientPaths(type.gqltype, args, operation);
2823
+ const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, operation));
2824
+ const params = entry === undefined
2825
+ ? { type, args, operation, context }
2826
+ : { type, args, operation, entry, context };
2827
+ await executeMiddleware(params);
2828
+ const extended = extend ? await extend(params, issued) : null;
2829
+ await executeScope(params);
2830
+ if (joinedScopes.size) await applyJoinedScopes(joinedScopes, params);
2831
+ if (extended) await applyJoinedScopes(extended, params);
2832
+ const filling = settleClientArgs(type.gqltype, params, operation, issued);
2833
+ if (filling) await filling;
2834
+ return params;
2835
+ };
2836
+
2837
+ // The tail of a list read, shared by the generated list root and the views: the rows and, when the
2838
+ // client asks for pagination.count and something can receive it, the count, reported through the sink.
2839
+ const readList = async (type, params, parent, info, context) => {
2840
+ const queryArgs = params.args;
2841
+ const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2842
+ && canReportCount(parent, info, context);
2843
+
2844
+ const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
2845
+ const countPromise = wantsCount
2846
+ ? adapter.count(type.model, type.gqltype, queryArgs, null)
2847
+ : null;
2848
+
2849
+ const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
2850
+ if (wantsCount && !reportCount(parent, info, context, resultCount)) {
2851
+ warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
2852
+ + 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
2853
+ + 'reported; pass a fresh mutable context object for each request.');
2854
+ }
2855
+ return result;
2856
+ };
2857
+
2858
+ // Separates a view's params, named by `paramNames`, from the rest of its arguments, which are exactly
2859
+ // a generated list's.
2860
+ const splitViewArgs = (paramNames, args) => {
2861
+ const params = {};
2862
+ const rest = {};
2863
+ for (const [name, value] of Object.entries(args)) {
2864
+ if (paramNames.has(name)) params[name] = value;
2865
+ else rest[name] = value;
2866
+ }
2867
+ return { params, args: rest };
2868
+ };
2869
+
2870
+ const invalidWhere = (view, detail) => new SimfinityError(`View ${view.name} where ${detail}`, 'INVALID_VIEW', 500);
2871
+
2872
+ // The groups a view's `where` returned are plain objects whose `conditions`, `AND` and `OR`, when set,
2873
+ // are arrays: of plain objects with a field name, and of groups like these. collectQueryPathEntries
2874
+ // skips anything else silently, so this comes before their paths are read. Only the GraphQL input
2875
+ // fields are structural keys; condition values stay opaque. A condition never carries a client scope
2876
+ // marker key: only the runtime writes one. Active ancestors reject cycles without rejecting reuse.
2877
+ const assertViewGroupShapes = (view, groups, ancestors = new Set()) => {
2878
+ const groupFields = QLFilterGroup.getFields();
2879
+ const conditionFields = QLFilterCondition.getFields();
2880
+ for (const group of groups) {
2881
+ if (!isNonNullPlainObject(group)) throw invalidWhere(view, 'returned a group that is not a plain object');
2882
+ if (ancestors.has(group)) throw invalidWhere(view, 'returned a cyclic group');
2883
+ for (const key of Object.keys(group)) {
2884
+ if (!Object.hasOwn(groupFields, key)) throw invalidWhere(view, `returned a group with unknown key ${key}`);
2885
+ }
2886
+ for (const key of Object.keys(groupFields)) {
2887
+ if (group[key] != null && !Array.isArray(group[key])) throw invalidWhere(view, `returned a group whose ${key} is not an array`);
2888
+ }
2889
+ for (const condition of group.conditions ?? []) {
2890
+ if (!isNonNullPlainObject(condition) || typeof condition.field !== 'string' || condition.field === '') {
2891
+ throw invalidWhere(view, 'returned a condition that is not a plain object with a field name');
2892
+ }
2893
+ if (hasScopeMarker(condition)) {
2894
+ throw invalidWhere(view, `condition on ${condition.field} carries a ${SCOPE_MARKER} key, which Simfinity reserves`);
2895
+ }
2896
+ for (const key of Object.keys(condition)) {
2897
+ if (!Object.hasOwn(conditionFields, key)) throw invalidWhere(view, `condition on ${condition.field} has unknown key ${key}`);
2898
+ }
2899
+ }
2900
+ ancestors.add(group);
2901
+ assertViewGroupShapes(view, [...(group.AND ?? []), ...(group.OR ?? [])], ancestors);
2902
+ ancestors.delete(group);
2903
+ }
2904
+ };
2905
+
2906
+ // A `where` that does not return an array of groups, or names a field its type does not have, a path
2907
+ // past a field without fields, or a virtual field, is the author's bug: INVALID_VIEW (500), never the
2908
+ // FORBIDDEN_FILTER_PATH (403) that inspectClientPaths gives a client for a virtual field. Each path is
2909
+ // walked here, since walkQueryPath stops silently at an unknown segment.
2910
+ const assertViewGroups = (view, groups) => {
2911
+ if (!Array.isArray(groups)) throw invalidWhere(view, 'must return an array of AND groups');
2912
+ assertViewGroupShapes(view, groups);
2913
+ for (const { segments } of collectQueryPathEntries({ AND: groups }, 'find', { gqltype: view.type })) {
2914
+ let type = view.type;
2915
+ let parent = null;
2916
+ for (const [index, segment] of segments.entries()) {
2917
+ const names = (detail) => invalidWhere(view, `names ${segments.slice(0, index + 1).join('.')}, which is ${detail}`);
2918
+ if (typeof type.getFields !== 'function') throw names(`past ${parent}, whose type ${type.name} has no fields`);
2919
+ const fields = type.getFields();
2920
+ if (!Object.hasOwn(fields, segment)) throw names(`not a field of ${type.name}`);
2921
+ if (isVirtualField(fields[segment])) throw names(`the virtual field ${type.name}.${segment}`);
2922
+ parent = `${type.name}.${segment}`;
2923
+ type = unwrapListAndNonNull(fields[segment].type);
2924
+ }
2925
+ }
2926
+ };
2927
+
2928
+ // The `extend` of a view's read (see prepareRead). After middleware, the groups of `where` are read as
2929
+ // the client's constructs on a probe, whose AND holds copies of them (inspectClientPaths), so the
2930
+ // author's objects are never touched: their paths are inspected, their conditions checked and marked,
2931
+ // with ids that continue the client's, then the copies are appended to the top-level AND, where no
2932
+ // middleware can drop them. Returns their joined scopes, which attach to the appended copies. Their
2933
+ // paths are the author's, as a scope's are, so a non-queryable field is not rejected.
2934
+ const viewExtension = (view, viewParams) => async (params, issued) => {
2935
+ const groups = await view.where({ params: viewParams, context: params.context });
2936
+ assertViewGroups(view, groups);
2937
+ const probe = { AND: groups };
2938
+ const scopes = inspectClientPaths(view.type, probe, 'find', { checkQueryable: false });
2939
+ const checked = assertQueryConditions(view.type, probe, 'find');
2940
+ markClientConstructs(checked, issued);
2941
+ appendAndGroups(params.args, probe.AND);
2942
+ return scopes;
2943
+ };
2944
+
2945
+ // A view's root field: the generated list of its type, with the view's params before the generated
2946
+ // arguments. Each call reads the type's registration, whose model createSchema prepares, strips the
2947
+ // params, by the names the field was built with, before the list pipeline runs, and has the pipeline
2948
+ // append the view's `where` after middleware (viewExtension).
2949
+ const buildViewField = (view) => {
2950
+ const paramArgs = {};
2951
+ for (const [paramName, param] of Object.entries(view.params)) {
2952
+ const arg = { type: param.type };
2953
+ if (Object.hasOwn(param, 'description')) arg.description = param.description;
2954
+ if (Object.hasOwn(param, 'defaultValue')) arg.defaultValue = param.defaultValue;
2955
+ paramArgs[paramName] = arg;
2956
+ }
2957
+ const paramNames = new Set(Object.keys(paramArgs));
2958
+ return {
2959
+ type: new GraphQLList(view.type),
2960
+ description: view.description,
2961
+ args: { ...paramArgs, ...createArgsForQuery(view.type) },
2962
+ extensions: {
2963
+ simfinityQuery: {
2964
+ typeName: view.type.name, operation: 'find', countSink: true, view: { name: view.name },
2965
+ },
2966
+ },
2967
+ async resolve(parent, args, context, info) {
2968
+ const registration = typesDict.types[view.type.name];
2969
+ const split = splitViewArgs(paramNames, args);
2970
+ const params = await prepareRead(registration, split.args, context, 'find', {
2971
+ entry: view.name, extend: viewExtension(view, split.params),
2972
+ });
2973
+ return readList(registration, params, parent, info, context);
2974
+ },
2975
+ };
2976
+ };
2977
+
2808
2978
  const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggregationResult, includedCustomQueries = null) => {
2809
2979
  const rootQueryArgs = {};
2810
2980
  rootQueryArgs.name = name;
@@ -2831,35 +3001,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2831
3001
  // that supplies a root-value sink can pass its context unchanged.
2832
3002
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
2833
3003
  async resolve(parent, args, context, info) {
2834
- const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
2835
- const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'find'));
2836
- const params = {
2837
- type,
2838
- args,
2839
- operation: 'find',
2840
- context,
2841
- };
2842
- await executeMiddleware(params);
2843
- await executeScope(params);
2844
- await applyJoinedScopes(joinedScopes, params);
2845
- const filling = settleClientArgs(type.gqltype, params, 'find', issued);
2846
- if (filling) await filling;
2847
- const queryArgs = params.args;
2848
- const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2849
- && canReportCount(parent, info, context);
2850
-
2851
- const dataPromise = adapter.find(type.model, type.gqltype, queryArgs, null);
2852
- const countPromise = wantsCount
2853
- ? adapter.count(type.model, type.gqltype, queryArgs, null)
2854
- : null;
2855
-
2856
- const [result, resultCount] = await Promise.all([dataPromise, countPromise]);
2857
- if (wantsCount && !reportCount(parent, info, context, resultCount)) {
2858
- warnOnce(runtimeIdentity, 'count-context', 'Configuration issue: the GraphQL context does not accept '
2859
- + 'a count property (it is frozen, sealed or has a read-only count), so pagination counts are not '
2860
- + 'reported; pass a fresh mutable context object for each request.');
2861
- }
2862
- return result;
3004
+ const params = await prepareRead(type, args, context, 'find');
3005
+ return readList(type, params, parent, info, context);
2863
3006
  },
2864
3007
  };
2865
3008
 
@@ -2879,19 +3022,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2879
3022
  args: aggregateArgsObject,
2880
3023
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
2881
3024
  async resolve(parent, args, context) {
2882
- const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
2883
- const issued = markClientConstructs(assertQueryConditions(type.gqltype, args, 'aggregate'));
2884
- const params = {
2885
- type,
2886
- args,
2887
- operation: 'aggregate',
2888
- context,
2889
- };
2890
- await executeMiddleware(params);
2891
- await executeScope(params);
2892
- await applyJoinedScopes(joinedScopes, params);
2893
- const filling = settleClientArgs(type.gqltype, params, 'aggregate', issued);
2894
- if (filling) await filling;
3025
+ const params = await prepareRead(type, args, context, 'aggregate');
2895
3026
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2896
3027
  },
2897
3028
  };
@@ -2906,6 +3037,13 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2906
3037
  }
2907
3038
  }
2908
3039
 
3040
+ // A view is in the schema exactly when its type is; assertViewsAvailable has checked its name.
3041
+ for (const view of Object.values(registeredViews)) {
3042
+ if (!shouldNotBeIncludedInSchema(includedTypes, view.type)) {
3043
+ rootQueryArgs.fields[view.name] = buildViewField(view);
3044
+ }
3045
+ }
3046
+
2909
3047
  return new GraphQLObjectType(rootQueryArgs);
2910
3048
  };
2911
3049
 
@@ -3202,13 +3340,50 @@ const queryNameConflict = (name) => new SimfinityError(
3202
3340
  'QUERY_NAME_CONFLICT', 500,
3203
3341
  );
3204
3342
 
3343
+ // The root names of the generated queries of the endpoint types a schema includes.
3344
+ const generatedQueryNames = (includedQueryTypes) => Object.values(typesDict.types)
3345
+ .filter((type) => type.simpleEntityEndpointName && !shouldNotBeIncludedInSchema(includedQueryTypes, type.gqltype))
3346
+ .flatMap((type) => [type.simpleEntityEndpointName, type.listEntitiesEndpointName, `${type.listEntitiesEndpointName}_aggregate`]);
3347
+
3348
+ const isIncludedCustomQuery = (name, includedCustomQueries) => Object.hasOwn(registeredQueries, name)
3349
+ && !shouldNotBeIncludedInSchema(includedCustomQueries, name);
3350
+
3205
3351
  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);
3352
+ for (const name of generatedQueryNames(includedQueryTypes)) {
3353
+ if (isIncludedCustomQuery(name, includedCustomQueries)) throw queryNameConflict(name);
3354
+ }
3355
+ };
3356
+
3357
+ // Every registered view, whether or not the schema includes it: its type is an endpoint type of this
3358
+ // runtime, and its params are valid argument names that are neither fields of the type, which covers
3359
+ // stored, reference, collection and virtual fields, nor the generated list arguments. A view that the
3360
+ // schema includes, because it includes the view's type, also needs a name that no included generated
3361
+ // query or custom query takes.
3362
+ const assertViewsAvailable = (includedQueryTypes, includedCustomQueries) => {
3363
+ const views = Object.values(registeredViews);
3364
+ if (!views.length) return;
3365
+ const generatedNames = new Set(generatedQueryNames(includedQueryTypes));
3366
+ for (const { name, type, params } of views) {
3367
+ const registration = typesDict.types[type.name];
3368
+ if (!registration || registration.gqltype !== type || registration.endpoint !== true
3369
+ || !registration.simpleEntityEndpointName) {
3370
+ throw invalidView(`${name} needs a type registered with connect() in this runtime`);
3371
+ }
3372
+ const fields = type.getFields();
3373
+ for (const paramName of Object.keys(params)) {
3374
+ if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(paramName) || paramName.startsWith('__')) {
3375
+ throw invalidView(`${name} param ${paramName} requires a valid GraphQL name that does not start with __`);
3376
+ }
3377
+ if (Object.hasOwn(fields, paramName)) {
3378
+ throw invalidView(`${name} param ${paramName} has the name of a field of ${type.name}`);
3211
3379
  }
3380
+ if (LIST_QUERY_ARGS.includes(paramName)) {
3381
+ throw invalidView(`${name} param ${paramName} has the name of a generated list query argument`);
3382
+ }
3383
+ }
3384
+ if (!shouldNotBeIncludedInSchema(includedQueryTypes, type)
3385
+ && (generatedNames.has(name) || isIncludedCustomQuery(name, includedCustomQueries))) {
3386
+ throw queryNameConflict(name);
3212
3387
  }
3213
3388
  }
3214
3389
  };
@@ -3241,6 +3416,7 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
3241
3416
  // Reject before models, resolvers or roots are built, so a rejected schema changes nothing.
3242
3417
  const { includedCustomQueries } = readSchemaOptions(options);
3243
3418
  assertQueryNamesAvailable(includedQueryTypes, includedCustomQueries);
3419
+ assertViewsAvailable(includedQueryTypes, includedCustomQueries);
3244
3420
  Object.values(typesDict.types).forEach(({ gqltype }) => validateScope(gqltype));
3245
3421
  const schemaTypes = collectSchemaOutputTypes();
3246
3422
  schemaTypes.forEach(assertNotBoundElsewhere);
@@ -3385,6 +3561,64 @@ const registerCustomQuery = (name, description, inputModel, outputModel, callbac
3385
3561
  registeredQueries[name] = registration;
3386
3562
  };
3387
3563
 
3564
+ const invalidView = (detail) => new SimfinityError(`registerView ${detail}`, 'INVALID_VIEW', 500);
3565
+
3566
+ // A view parameter is a value the client sends as a variable or a literal: a scalar, an enum or a list
3567
+ // of them, each possibly NonNull. An input object is never one, wherever it is nested.
3568
+ const isViewParamType = (type) => {
3569
+ if (isNonNullType(type) || isListType(type)) return isViewParamType(type.ofType);
3570
+ return isScalarType(type) || isEnumType(type);
3571
+ };
3572
+
3573
+ const isNonNullPlainObject = (value) => value !== null && typeof value === 'object' && isPlainObject(value);
3574
+
3575
+ const VIEW_OPTION_KEYS = new Set(['type', 'description', 'params', 'where']);
3576
+ const VIEW_PARAM_KEYS = new Set(['type', 'description', 'defaultValue']);
3577
+
3578
+ const validateViewParams = (params) => {
3579
+ if (params === undefined) return;
3580
+ if (!isNonNullPlainObject(params)) throw invalidView('params must be an object');
3581
+ for (const [paramName, param] of Object.entries(params)) {
3582
+ if (!isNonNullPlainObject(param)) throw invalidView(`param ${paramName} must be an object`);
3583
+ for (const key of Object.keys(param)) {
3584
+ if (!VIEW_PARAM_KEYS.has(key)) throw invalidView(`param ${paramName} has an unknown key ${key}`);
3585
+ }
3586
+ if (!isViewParamType(param.type)) {
3587
+ throw invalidView(`param ${paramName} type must be a scalar, an enum or a list of them, not an input object`);
3588
+ }
3589
+ if (param.description !== undefined && typeof param.description !== 'string') {
3590
+ throw invalidView(`param ${paramName} description must be a string`);
3591
+ }
3592
+ }
3593
+ };
3594
+
3595
+ const validateViewRegistration = (name, options) => {
3596
+ if (typeof name !== 'string' || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(name) || name.startsWith('__')) {
3597
+ throw invalidView('requires a valid GraphQL field name that does not start with __');
3598
+ }
3599
+ if (name.endsWith('_aggregate')) {
3600
+ throw invalidView(`name ${name} must not end with _aggregate, which the generated aggregate queries use`);
3601
+ }
3602
+ if (!isNonNullPlainObject(options)) throw invalidView('requires an options object');
3603
+ for (const key of Object.keys(options)) {
3604
+ if (!VIEW_OPTION_KEYS.has(key)) throw invalidView(`has an unknown option ${key}`);
3605
+ }
3606
+ if (!(options.type instanceof GraphQLObjectType)) throw invalidView('type must be a GraphQLObjectType');
3607
+ if (typeof options.description !== 'string' || options.description === '') {
3608
+ throw invalidView('requires a non-empty description');
3609
+ }
3610
+ validateViewParams(options.params);
3611
+ if (typeof options.where !== 'function') throw invalidView('where must be a function');
3612
+ };
3613
+
3614
+ const registerView = (name, options) => {
3615
+ validateViewRegistration(name, options);
3616
+ if (Object.hasOwn(registeredViews, name)) throw queryNameConflict(name);
3617
+ registeredViews[name] = {
3618
+ name, type: options.type, description: options.description, params: options.params ?? {}, where: options.where,
3619
+ };
3620
+ };
3621
+
3388
3622
  // Gives the field a new extensions object as well as the resolver, so a copy made before keeps
3389
3623
  // untagged extensions. It has a null prototype because graphql-js keeps such an object, rather than
3390
3624
  // copying it, when a type is rebuilt from toConfig().
@@ -3429,14 +3663,7 @@ const autoGenerateResolvers = (gqltype) => {
3429
3663
  if (!relatedTypeInfo || !relatedTypeInfo.model) {
3430
3664
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
3431
3665
  }
3432
- const joinedScopes = inspectClientPaths(relatedType, args, 'find');
3433
- const issued = markClientConstructs(assertQueryConditions(relatedType, args, 'find'));
3434
- const params = { type: relatedTypeInfo, args, operation: 'find', context };
3435
- await executeMiddleware(params);
3436
- await executeScope(params);
3437
- await applyJoinedScopes(joinedScopes, params);
3438
- const filling = settleClientArgs(relatedType, params, 'find', issued);
3439
- if (filling) await filling;
3666
+ const params = await prepareRead(relatedTypeInfo, args, context, 'find');
3440
3667
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
3441
3668
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
3442
3669
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
@@ -3622,6 +3849,7 @@ function formatArgs(argsArray) {
3622
3849
  use,
3623
3850
  registerMutation,
3624
3851
  registerCustomQuery,
3852
+ registerView,
3625
3853
  saveObject,
3626
3854
  preventCreatingCollection,
3627
3855
  };
package/types/index.d.ts CHANGED
@@ -2,6 +2,8 @@ import type {
2
2
  GraphQLEnumType,
3
3
  GraphQLError,
4
4
  GraphQLInputObjectType,
5
+ GraphQLList,
6
+ GraphQLNonNull,
5
7
  GraphQLObjectType,
6
8
  GraphQLOutputType,
7
9
  GraphQLScalarType,
@@ -42,6 +44,26 @@ export type QueryOperator =
42
44
  | 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ'
43
45
  | 'IS_EMPTY' | 'IS_NOT_EMPTY';
44
46
 
47
+ /**
48
+ * A condition of a {@link FilterGroup}, as the `QLFilterCondition` input takes it: the field it
49
+ * tests, its {@link QueryOperator} (`EQ` when omitted) and its value. On an object field, `path`
50
+ * names the member it tests; `IS_EMPTY` and `IS_NOT_EMPTY` take no value and may omit `path` to test
51
+ * the field itself.
52
+ */
53
+ export interface FilterCondition {
54
+ field: string;
55
+ operator?: QueryOperator | (string & {});
56
+ value?: any;
57
+ path?: string;
58
+ }
59
+
60
+ /** A `QLFilterGroup` value: flat conditions plus nested `AND` and `OR` groups. */
61
+ export interface FilterGroup {
62
+ AND?: FilterGroup[];
63
+ OR?: FilterGroup[];
64
+ conditions?: FilterCondition[];
65
+ }
66
+
45
67
  /**
46
68
  * The query language features a {@link DatabaseAdapter} compiles. Declare it so that the runtime
47
69
  * sends the adapter only conditions it compiles; later releases add members for later features.
@@ -327,6 +349,42 @@ export interface QueryLimitsOptions {
327
349
  maxReferenceFrames?: number;
328
350
  }
329
351
 
352
+ /**
353
+ * The type of a view parameter: a value the client sends as a variable or a literal, so a scalar,
354
+ * an enum or a list of them, each possibly non-null, never an input object.
355
+ */
356
+ export type ViewParamType =
357
+ | GraphQLScalarType
358
+ | GraphQLEnumType
359
+ | GraphQLList<ViewParamType>
360
+ | GraphQLNonNull<GraphQLScalarType | GraphQLEnumType | GraphQLList<ViewParamType>>;
361
+
362
+ /** A view parameter: an argument of the view's root field that only its `where` reads. */
363
+ export interface ViewParam {
364
+ type: ViewParamType;
365
+ description?: string;
366
+ defaultValue?: unknown;
367
+ }
368
+
369
+ /** The declaration of a named view (see {@link Runtime.registerView}). */
370
+ export interface ViewOptions {
371
+ /** The connected endpoint type whose list the view returns. */
372
+ type: GraphQLObjectType;
373
+ /** Non-empty; the root field's description. */
374
+ description: string;
375
+ /**
376
+ * Arguments of the root field besides the generated list arguments, named unlike them and unlike
377
+ * the fields of `type`. They never reach the adapter: only `where` receives them.
378
+ */
379
+ params?: Record<string, ViewParam>;
380
+ /**
381
+ * Called on every read with the param values and the request context. The groups it returns are
382
+ * appended to the client's filters after middleware, so neither the client nor middleware can drop
383
+ * them; their paths may name fields that are not queryable.
384
+ */
385
+ where: (input: { params: Record<string, any>; context: any }) => FilterGroup[] | Promise<FilterGroup[]>;
386
+ }
387
+
330
388
  export interface Runtime<Model = any, Session = any> {
331
389
  /** The same process-wide setting as the root `configureQueryLimits`. */
332
390
  configureQueryLimits(options?: QueryLimitsOptions): void;
@@ -410,6 +468,17 @@ export interface Runtime<Model = any, Session = any> {
410
468
  * (500) for a name already registered.
411
469
  */
412
470
  registerCustomQuery(name: string, description: string, input: GraphQLInputObjectType | null | undefined, output: GraphQLOutputType, callback: (input: any, context: any) => any): void;
471
+ /**
472
+ * Register a named view: a root query field that returns the list of `options.type` with the
473
+ * generated list arguments plus `options.params`, and appends the groups of `options.where` after
474
+ * middleware. A schema includes the view exactly when it includes the type. Throws `INVALID_VIEW`
475
+ * (500) for an invalid declaration and `QUERY_NAME_CONFLICT` (500) for a name already registered as
476
+ * a view, keeping the first registration. `createSchema` throws `INVALID_VIEW` when the type is not
477
+ * connected in this runtime or a param is named like one of its fields or a generated list
478
+ * argument, and `QUERY_NAME_CONFLICT` when an included view is named like an included generated or
479
+ * custom query.
480
+ */
481
+ registerView(name: string, options: ViewOptions): void;
413
482
  /** Owns the full workflow transaction unless an active caller session is supplied. */
414
483
  saveObject(typeName: string, args: Record<string, any>, session?: Session, context?: any): Promise<any>;
415
484
  preventCreatingCollection(prevent: boolean): void;