@simtlix/simfinity-core 3.7.0 → 3.9.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
@@ -16,26 +16,30 @@ For relational storage, `@simtlix/simfinity-sql` builds on core and delegates ph
16
16
  - `preventCreatingCollection` to pass validation-only storage setup to an adapter;
17
17
  - `configureQueryLimits` and `configureMutationLimits` for the process-wide page-size and nested-operation limits.
18
18
 
19
- The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. An adapter can define `readByIdIsInert(model)`, returning `true` when a `getById` of that model has no side effects and `update()` and `delete()` reject the same malformed IDs with `NOT_VALID_ID` (400) and return null for a missing record; the runtime then skips the reads before deletes without an `onDelete` hook and before updates that merge no embedded value and have neither `onUpdating` nor nested collection items. The SQL adapter always reports it, and the MongoDB adapter does for models without `find`, `findOne`, `findOneAndUpdate` or `findOneAndDelete` middleware and without application `_id` setters or overrides of the statics those reads and writes call. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. Simfinity then reads as `null` an object that holds no data when a required member would read as `null`, not counting `id`, `readOnly`, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored `_id` as `id`; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define `rawEmbeddedValue(value)`, which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
19
+ The adapter supplies model creation, identifiers, transactions, record reads and writes, queries, counts, aggregations, and inverse collection reads. `DatabaseAdapter`, `Runtime`, controller, middleware, registration, and state-machine interfaces are exported in the TypeScript declarations. A runtime binds one adapter; create another runtime to use another database or configuration. The GraphQL types that `prepare()` and `createModel()` receive still contain virtual fields, which must never be stored: an adapter that builds storage from a type's fields skips each field for which `isVirtualField(field)`, exported by this package with `virtualFieldOptions(field)`, returns true, on entities and embedded types alike, or builds it from `describeModels()`, which leaves them out. Generated writes, filters, sorts and aggregations never name a virtual field, but a controller hook can still assign one. An adapter can also define `getByIds(model, ids, { context })`, which returns the records it finds in `getById` shape and any order; the runtime then batches generated single-reference reads per request and type, matching records by `castId`, splitting batches at `maxPageSize`, and reading an ID with `getById` when a batch read fails or when `castId` would normalize or reject it. `castId` should throw a `NOT_VALID_ID` (400) `SimfinityError` for a malformed value and must not create a new identifier. An adapter can define `readByIdIsInert(model)`, returning `true` when a `getById` of that model has no side effects and `update()` and `delete()` reject the same malformed IDs with `NOT_VALID_ID` (400) and return null for a missing record; the runtime then skips the reads before deletes without an `onDelete` hook and before updates that merge no embedded value and have neither `onUpdating` nor nested collection items. The SQL adapter always reports it, and the MongoDB adapter does for models without `find`, `findOne`, `findOneAndUpdate` or `findOneAndDelete` middleware and without application `_id` setters or overrides of the statics those reads and writes call. When `getById` rejects a stored reference value that way, including one that `castId` rejected first, the generated reference field fails with an `InternalServerError` (500), since the client did not send the value. `update()` resolves to the updated record as stored, or null; nested collection children of that record are linked to and checked against its `_id`, else `id`, else the requested ID. An adapter that hydrates records can define `readEmbeddedValue(value)`: every nullable, singular embedded object field without a resolver then passes its value through it, so an explicitly stored `null` reads as `null`. The field is read as graphql's default resolver reads it, so a method on the parent is called and a promise it returns is awaited before the hook receives the value. Simfinity then reads as `null` an object that holds no data when a required member would read as `null`, not counting `id`, `readOnly` and virtual members, interface and union members, members with an application resolver and referenced collections; runtimes that share the type read the same. It reads the stored members, never calling a getter, and counts a stored `_id` as `id`; it reads nothing from objects whose type has no member that counts, and an error while reading returns the object unchanged. An adapter whose hydrated records render members through getters can also define `rawEmbeddedValue(value)`, which returns a record's stored data as a plain object, or the record unchanged when that data does not show what it renders; a value that is not a plain object counts as data. The MongoDB adapter defines both.
20
20
 
21
21
  An adapter can also define `findChildrenBatch(model, type, connectionField, parentIds, args, { context })`, the batch form of `findChildren` for generated collection fields, and a synchronous `batchesChildren(model, type, connectionField)` predicate. The runtime calls both as adapter methods and checks them on every read. When the method is missing or the predicate returns `false`, each field calls `findChildren` at once with the arguments object it built, as before. Otherwise, within one request (a context object), the runtime groups the collection reads of one related type and connection storage field whose effective arguments (after middleware, the scope and joined scopes) hold the same plain data and are requested in the same tick, that is before the promise jobs queued so far have run. It calls the method with 2 to 100 distinct parent IDs, each a raw ID that `castId` accepts without changing its string form, and with a copy of the arguments that the method may change; a group with one distinct parent calls `findChildren`. Return one array per parent ID, in order, each holding exactly the records, order and page that `findChildren` returns for that parent; records should show their parent under the connection field, or not show one. Fields of one parent share its records, each with its own array. After a rejection, or a result of another shape or with records of another parent, each field reads alone with `findChildren`; failures other than a `SimfinityError`, and results of another shape, are logged once per type. Pairing rule: the batch must reproduce `this.findChildren`, so `batchesChildren` must return `false` when `this.findChildren` is not the adapter's own function, as with a spread copy or an `Object.assign` override that changes how children are read. The bundled adapters follow this rule: both define the two members only with `batchedCollections: true`: the SQL adapter when the schema is prepared and its plugin's `compileChildrenQuery` is paired with its `compileQuery`, and the MongoDB adapter at creation. To opt out on one adapter instance, set `adapter.findChildrenBatch = undefined` (deleting it does nothing for a class whose method is on the prototype), or make `batchesChildren` return `false`. A copy that overrides `getById` still inherits the base `getByIds`, as before.
22
22
 
23
+ An adapter declares the filter operators it compiles as `queryFeatures: { operators }`, a list of `QLOperator` values that includes `EQ`, which a condition without an operator means; names this version does not know are ignored. `createRuntime()` reads it once and throws `INVALID_QUERY_FEATURES` (500) unless it is `undefined`, `null` or an object whose `operators` is an array of strings. Without a declaration the adapter supports the operators of 3.7.0, `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`, so it never receives a text operator such as `ILIKE` that it would drop or misread. A list, count, aggregate or collection read, or a by-ID read through a `get_by_id` scope, that uses another `QLOperator` value fails with `UNSUPPORTED_QUERY` (400): the runtime checks the client's arguments before middleware runs and the final arguments, with what middleware, scopes and joined scopes added, before it calls the adapter. Operators that `QLOperator` does not list still reach the adapter. An adapter that receives the text operators must apply their rules: literal values, a `String` field or list, and `INVALID_FILTER_VALUE` (400) for a value that is not a string or contains U+0000. `createQueryPlan(models, entity, args, { operators })` validates them and rejects operators outside `operators` with `UNSUPPORTED_QUERY`.
24
+
23
25
  Entity types, those registered with `connect()` and the targets of non-embedded relations, resolve `id` to `_id`, falling back to `id`, whatever the `createSchema()` allowlists include; types used only as embedded values keep their declared `id`. Embedded copies of an entity type resolve the same way, so they return their declared `id` unless the adapter stores an `_id` for them, as MongoDB does for embedded list items. The resolver is set on the type object, so it applies in every runtime that shares the type. `createSchema()` marks these registrations with `storedIdentity: true` before calling `adapter.prepare()`.
24
26
 
25
27
  When a runtime first creates a schema, it binds each GraphQL object type whose non-embedded relation fields receive its generated resolvers. It also reserves each reachable object type that still has a non-embedded relation field without a resolver, such as an unregistered custom mutation result. Registering a bound or reserved type in another runtime, or building another runtime's schema that reaches it through fields, interfaces, union members or custom mutation results, throws `TYPE_BOUND_TO_OTHER_RUNTIME` (409). `createSchema()` also rejects a type with a relation field copied, by `toConfig()` or a field spread, after another runtime generated that field's resolver. Create type objects once per runtime, for example with a factory function. Types without non-embedded relation fields, and types whose relation fields all have application resolvers, can be shared, and another runtime can use a `toConfig()` copy made before the first runtime built its schema. A shared custom mutation result type with an unresolved relation field is rejected.
26
28
 
27
- Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`). Before `adapter.prepare()`, `createSchema()` rejects a writable non-embedded list relation without a non-empty child `connectionField` whose resolver it would generate (`INVALID_MODEL`, 400). A collection without `connectionField` that has an application resolver, or is `readOnly`, only logs a `Configuration issue` warning, unless it is both, and nested writes through it fail with `INVALID_MODEL` (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in `prepare()`. Fields named like generated query arguments (`sort`, `pagination`, `AND`, `OR`, and `aggregation` on aggregate endpoints) also log a warning once per runtime. Generated inputs that would have no fields are left out with a `Configuration issue` warning, with the collection operations, embedded fields and mutations that would take them; a non-null embedded field whose embedded type has no writable fields throws `INVALID_MODEL` (400) when generated mutations reach its owner. An application type named `FieldExtensionsType` or `RelationType` makes Simfinity rename its introspection metadata types to `SimfinityFieldExtensionsType` and `SimfinityRelationType` when it is in the process's first Simfinity schema. That schema fixes the names: in a later `createSchema()`, an application type with one of the current names, the original ones or the fallback names after a rename, throws `RESERVED_TYPE_NAME` (500), before `adapter.prepare()` when it is an included endpoint type, a registered mutation's input or output type, an output type they reach or a field argument's type, and when the schema is constructed otherwise, such as for a type inside an input object's fields.
29
+ Registration and `createSchema()` validate `extensions.scope` (`INVALID_SCOPE`) and reject list or embedded relations to unregistered types (`UNREGISTERED_RELATION_TARGET`); `use()` rejects non-functions (`INVALID_MIDDLEWARE`). Before `adapter.prepare()`, `createSchema()` rejects a writable non-embedded list relation without a non-empty child `connectionField` whose resolver it would generate (`INVALID_MODEL`, 400). A collection without `connectionField` that has an application resolver, or is `readOnly`, only logs a `Configuration issue` warning, unless it is both, and nested writes through it fail with `INVALID_MODEL` (500) before anything is written; adapters that describe the whole model, such as PostgreSQL and transactional MongoDB, still reject it in `prepare()`. Fields named like generated query arguments (`sort`, `pagination`, `AND`, `OR`, and `aggregation` on aggregate endpoints) also log a warning once per runtime. Virtual fields (`extensions: { virtual: true }` or `{ virtual: { mcp: 'tool' | 'select' | false } }`, with the field's own `resolve`) are never stored, get no generated input and no filter argument, and resolve wherever their type appears; `createSchema()` rejects an invalid declaration, a virtual field without its own resolver or with `relation`, `unique: true`, `validations`, `queryable: true` or `readOnly: false`, and a virtual identity, index, state-machine `state` or `connectionField` field (`INVALID_MODEL`, 400). A client filter, sort, `groupId` or fact path through a virtual field fails with `FORBIDDEN_FILTER_PATH` (403), and one that middleware or a scope adds fails with `INVALID_MODEL` (500). A `readOnly` field with arguments and its own resolver that is not virtual, a reference or a collection logs a `Configuration issue` warning once per runtime. Generated inputs that would have no fields are left out with a `Configuration issue` warning, with the collection operations, embedded fields and mutations that would take them; a non-null embedded field whose embedded type has no writable fields throws `INVALID_MODEL` (400) when generated mutations reach its owner. An application type named `FieldExtensionsType` or `RelationType` makes Simfinity rename its introspection metadata types to `SimfinityFieldExtensionsType` and `SimfinityRelationType` when it is in the process's first Simfinity schema. That schema fixes the names: in a later `createSchema()`, an application type with one of the current names, the original ones or the fallback names after a rename, throws `RESERVED_TYPE_NAME` (500), before `adapter.prepare()` when it is an included endpoint type, a registered mutation's input or output type, an output type they reach or a field argument's type, and when the schema is constructed otherwise, such as for a type inside an input object's fields.
28
30
 
29
31
  Generated and custom mutations run through `adapter.withTransaction`. Retried attempts receive fresh clones of GraphQL input objects, lists, and dates while enum values keep their identity. An adapter can define `stateValue` when its persisted enum representation differs from GraphQL enum names.
30
32
 
31
- Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
33
+ Importing the runtime installs Simfinity's `__Field.extensions` introspection field once per GraphQL module instance; its `readOnly` flag reads `true` for virtual fields. Multiple runtimes and driver packages can be imported together without cloning schemas or duplicating introspection types.
32
34
 
33
35
  ## Shared metadata and helpers
34
36
 
35
- `describeModels([{ gqltype, endpoint }])` discovers the persistent entity graph from `GraphQLObjectType` definitions and `extensions.relation`. Types registered with `endpoint: false` are still included when referenced. Reciprocal one-to-many definitions resolve to one child reference; explicit linking entities represent many-to-many relationships.
37
+ `describeModels([{ gqltype, endpoint }])` discovers the persistent entity graph from `GraphQLObjectType` definitions and `extensions.relation`, leaving virtual fields out and describing no type that only virtual fields reach. Types registered with `endpoint: false` are still included when referenced. Reciprocal one-to-many definitions resolve to one child reference; explicit linking entities represent many-to-many relationships.
36
38
 
37
39
  `createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
38
40
 
41
+ `isVirtualField(field)` tells whether a GraphQL field is virtual, and `virtualFieldOptions(field)` returns its normalized options, `{ mcp }`, or `null` for any other field. Adapters that build storage from GraphQL types use them to skip virtual fields.
42
+
39
43
  The package also exports the `auth`, `validators`, `scalars`, and `plugins` helper namespaces alongside `createValidatedScalar`, `SimfinityError`, `InternalServerError`, `UnauthenticatedError` and `ForbiddenError` (the same classes as `auth.UnauthenticatedError` and `auth.ForbiddenError`), `buildErrorFormatter`, `configureQueryLimits`, `configureMutationLimits`, `QLOperator`, `QLSort`, and `QLValue`. `buildErrorFormatter` returns a `GraphQLError` whose `originalError` is the classified Simfinity error. Database packages reuse these exports so helpers, errors, and globally named GraphQL types retain one identity.
40
44
 
41
45
  The same helpers are available from the `@simtlix/simfinity-core/auth`, `/auth/errors`, `/auth/expressions`, `/auth/rules`, `/plugins`, `/scalars` and `/validators` subpaths, each with TypeScript declarations whose types match the root namespaces. `moduleResolution` `node16`, `nodenext` and `bundler` read them through the export map, and `node10` through `typesVersions`. The `internal/*` subpaths are not public API and have no declarations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-core",
3
- "version": "3.7.0",
3
+ "version": "3.9.0",
4
4
  "description": "Driver-free GraphQL runtime and model metadata for Simfinity",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -52,6 +52,7 @@
52
52
  "default": "./src/auth/rules.js"
53
53
  },
54
54
  "./internal/object-id": "./src/auth/object-id.js",
55
+ "./internal/query-operators": "./src/query-operators.js",
55
56
  "./internal/relation-storage": "./src/relation-storage.js",
56
57
  "./plugins": {
57
58
  "types": "./types/plugins.d.ts",
@@ -13,6 +13,12 @@ const QLOperator = new GraphQLEnumType({
13
13
  IN: { value: 'IN' },
14
14
  NIN: { value: 'NIN' },
15
15
  LIKE: { value: 'LIKE' },
16
+ ILIKE: { value: 'ILIKE' },
17
+ STARTS_WITH: { value: 'STARTS_WITH' },
18
+ ISTARTS_WITH: { value: 'ISTARTS_WITH' },
19
+ ENDS_WITH: { value: 'ENDS_WITH' },
20
+ IENDS_WITH: { value: 'IENDS_WITH' },
21
+ IEQ: { value: 'IEQ' },
16
22
  },
17
23
  });
18
24
 
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { describeModels } from './metadata.js';
2
+ export { isVirtualField, virtualFieldOptions } from './relation-storage.js';
2
3
  export { default as SimfinityError } from './errors/simfinity.error.js';
3
4
  export { createValidatedScalar } from './scalars/factory.js';
4
5
  export {
@@ -4,6 +4,7 @@ import {
4
4
  GraphQLString,
5
5
  __Field,
6
6
  } from 'graphql';
7
+ import { isVirtualField } from './relation-storage.js';
7
8
 
8
9
  // The object types of `__Field.extensions`: FieldExtensionsType and the RelationType of its
9
10
  // `relation` field, under the given names.
@@ -27,6 +28,14 @@ const createFieldMetadataType = (extensionsName, relationName) => {
27
28
  });
28
29
  };
29
30
 
31
+ // The metadata `__Field.extensions` returns: the field's own extensions, except that a virtual field
32
+ // reads `readOnly: true`, which virtual implies. The metadata types keep their fields, so schemas
33
+ // without virtual fields introspect exactly as before; client metadata about virtual fields belongs
34
+ // in a descriptor of its own (#101), not in this process-wide extension.
35
+ const fieldMetadataOf = (field) => (isVirtualField(field)
36
+ ? { ...field.extensions, readOnly: true }
37
+ : field.extensions);
38
+
30
39
  // getFields() supports both lazy and already materialized introspection fields.
31
40
  // Reuse the shared field on repeated module evaluation to preserve type identity.
32
41
  const introspectionFields = __Field.getFields();
@@ -34,7 +43,7 @@ if (!introspectionFields.extensions) {
34
43
  introspectionFields.extensions = {
35
44
  type: createFieldMetadataType('FieldExtensionsType', 'RelationType'),
36
45
  name: 'extensions',
37
- resolve: (obj) => obj.extensions,
46
+ resolve: fieldMetadataOf,
38
47
  args: [],
39
48
  isDeprecated: false,
40
49
  };
package/src/metadata.js CHANGED
@@ -2,6 +2,10 @@ import {
2
2
  isObjectType, isScalarType, isEnumType, isNonNullType, isListType,
3
3
  } from 'graphql';
4
4
  import SimfinityError from './errors/simfinity.error.js';
5
+ import {
6
+ assertVirtualConnection, assertVirtualDeclaration, assertVirtualIndexes, assertVirtualStateField,
7
+ } from './virtual-fields.js';
8
+ import { assertPlainConnectionField } from './relation-storage.js';
5
9
 
6
10
  const invalid = (message) => {
7
11
  throw new SimfinityError(message, 'INVALID_MODEL', 400);
@@ -42,7 +46,11 @@ const scalarDescription = (type) => {
42
46
  return { scalar };
43
47
  };
44
48
 
45
- /** Describe persistent entities without importing a database driver or mutating GraphQL types. */
49
+ /**
50
+ * Describe persistent entities without importing a database driver or mutating GraphQL types.
51
+ * Virtual fields (`extensions.virtual`) store nothing, so they are left out of every entity and
52
+ * embedded type, and their result types are neither described nor required to declare a relation.
53
+ */
46
54
  export const describeModels = (registrations) => {
47
55
  if (!Array.isArray(registrations)) invalid('registrations must be an array');
48
56
  const knownTypes = new Map();
@@ -61,12 +69,16 @@ export const describeModels = (registrations) => {
61
69
  }
62
70
  };
63
71
  for (const registration of registrations) registerType(registration.gqltype);
72
+ for (const registration of registrations) assertVirtualStateField(registration.gqltype, registration.stateMachine);
64
73
  for (const registration of registrations) if (registration.endpoint !== false) addEntity(registration.gqltype);
65
74
 
66
75
  const fieldsOf = (type, ancestors, path, embedded = false) => {
67
76
  if (ancestors.includes(type)) invalid(`Embedded cycle at ${path}`);
68
77
  registerType(type);
69
- const fields = Object.values(type.getFields()).map((field) => {
78
+ const stored = Object.values(type.getFields()).filter((field) => !assertVirtualDeclaration(
79
+ type.name, field.name, field, () => typeof field.resolve === 'function',
80
+ ));
81
+ const fields = stored.map((field) => {
70
82
  const { named, ...wrappers } = unwrap(field.type);
71
83
  const extensions = field.extensions || {};
72
84
  const descriptor = {
@@ -79,12 +91,14 @@ export const describeModels = (registrations) => {
79
91
  if (!isObjectType(named)) invalid(`Unsupported field type at ${path}.${field.name}`);
80
92
  const relation = extensions.relation;
81
93
  if (!relation) invalid(`${path}.${field.name} requires extensions.relation`);
94
+ assertVirtualConnection(type, field.name, field);
82
95
  if (relation.embedded) {
83
96
  return {
84
97
  ...descriptor, kind: 'embedded',
85
98
  fields: fieldsOf(named, [...ancestors, type], `${path}.${field.name}`, true),
86
99
  };
87
100
  }
101
+ assertPlainConnectionField(type.name, field.name, field);
88
102
  addEntity(named);
89
103
  if (wrappers.list) {
90
104
  if (!relation.connectionField) invalid(`${path}.${field.name} requires a child connectionField`);
@@ -111,6 +125,7 @@ export const describeModels = (registrations) => {
111
125
  }
112
126
  return { fields: [...index.fields], unique: index.unique === true };
113
127
  });
128
+ assertVirtualIndexes(type);
114
129
  }
115
130
  for (const entity of entities.values()) {
116
131
  for (const field of entity.fields.filter((item) => item.kind === 'collection')) {
@@ -0,0 +1,80 @@
1
+ import QLOperator from './const/QLOperator.js';
2
+ import SimfinityError from './errors/simfinity.error.js';
3
+
4
+ // Internal: the filter operators and the value rules every backend applies to them. They are shared
5
+ // by the core runtime, the query plan and the MongoDB compiler through the
6
+ // `./internal/query-operators` subpath, which is not public API.
7
+
8
+ /** Every `QLOperator` value, in declaration order. QLOperator is the only list of operators. */
9
+ export const QUERY_OPERATORS = Object.freeze(QLOperator.getValues().map((entry) => entry.value));
10
+
11
+ /**
12
+ * The operators of 3.7.0. An adapter that declares no `queryFeatures` supports exactly these, so a
13
+ * custom adapter written for 3.7 never receives an operator it would drop or misread.
14
+ */
15
+ export const LEGACY_QUERY_OPERATORS = Object.freeze(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
16
+
17
+ /**
18
+ * The literal text operators (#108, part 1) and what each one matches: `contains`, `prefix`,
19
+ * `suffix` or `exact` (the whole value), respecting case or ignoring it (`ignoreCase`). Values are
20
+ * literal: `%`, `_`, `.`, `*`, `\`, `^`, `$` and `(` match themselves. `LIKE`, the case-sensitive
21
+ * substring of 3.7.0, keeps its own validation and compilation and is not listed here.
22
+ */
23
+ export const TEXT_QUERY_OPERATORS = Object.freeze({
24
+ ILIKE: Object.freeze({ match: 'contains', ignoreCase: true }),
25
+ STARTS_WITH: Object.freeze({ match: 'prefix', ignoreCase: false }),
26
+ ISTARTS_WITH: Object.freeze({ match: 'prefix', ignoreCase: true }),
27
+ ENDS_WITH: Object.freeze({ match: 'suffix', ignoreCase: false }),
28
+ IENDS_WITH: Object.freeze({ match: 'suffix', ignoreCase: true }),
29
+ IEQ: Object.freeze({ match: 'exact', ignoreCase: true }),
30
+ });
31
+
32
+ /** Whether `operator` is a `QLOperator` value. */
33
+ export const isQueryOperator = (operator) => QUERY_OPERATORS.includes(operator);
34
+
35
+ /** The {@link TEXT_QUERY_OPERATORS} entry of `operator`, or null for any other operator. */
36
+ export const textQueryOperator = (operator) => (
37
+ typeof operator === 'string' && Object.hasOwn(TEXT_QUERY_OPERATORS, operator) ? TEXT_QUERY_OPERATORS[operator] : null
38
+ );
39
+
40
+ const invalidValue = (message) => { throw new SimfinityError(message, 'INVALID_FILTER_VALUE', 400); };
41
+
42
+ /**
43
+ * Validates the value of a text operator condition before any backend compiles it, so both backends
44
+ * reject the same input with the same code. `scalar` names the effective scalar of the path's last
45
+ * field: the base scalar of a validated scalar (`String` for an email scalar), `Enum` or the enum
46
+ * type's name for an enum, `ID` for identifiers and references. The field must be a `String` field
47
+ * or a `String` list, and the value a string without U+0000; the empty string is valid. Anything
48
+ * else fails with `INVALID_FILTER_VALUE` (400), as `LIKE` does. Returns the value unchanged: it is
49
+ * not passed through the field's scalar, since a validated scalar filters by its base scalar.
50
+ */
51
+ export const validateTextFilterValue = (operator, value, scalar, path) => {
52
+ const where = path == null ? '' : ` on ${Array.isArray(path) ? path.join('.') : path}`;
53
+ if (scalar !== 'String') invalidValue(`${operator} requires a String field${where}`);
54
+ if (typeof value !== 'string') invalidValue(`${operator} requires a non-null string value${where}`);
55
+ if (value.includes('\u0000')) invalidValue(`${operator} values cannot contain U+0000${where}`);
56
+ return value;
57
+ };
58
+
59
+ const invalidFeatures = (message) => {
60
+ throw new SimfinityError(`DatabaseAdapter queryFeatures ${message}`, 'INVALID_QUERY_FEATURES', 500);
61
+ };
62
+
63
+ /**
64
+ * Reads an adapter's `queryFeatures` declaration into the set of operators it compiles. Without a
65
+ * declaration (undefined or null) the adapter supports {@link LEGACY_QUERY_OPERATORS}. A declaration
66
+ * is an object whose `operators` is an array of operator names; names this version does not
67
+ * know are ignored, and other members are left for later features. Anything else throws
68
+ * `INVALID_QUERY_FEATURES` (500).
69
+ */
70
+ export const resolveQueryFeatures = (queryFeatures) => {
71
+ if (queryFeatures == null) return { operators: new Set(LEGACY_QUERY_OPERATORS) };
72
+ if (typeof queryFeatures !== 'object' || Array.isArray(queryFeatures)) {
73
+ invalidFeatures('must be an object with an operators array');
74
+ }
75
+ const { operators } = queryFeatures;
76
+ if (!Array.isArray(operators) || operators.some((operator) => typeof operator !== 'string')) {
77
+ invalidFeatures('operators must be an array of operator names');
78
+ }
79
+ return { operators: new Set(operators.filter(isQueryOperator)) };
80
+ };
package/src/query-plan.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { paginationStages } from './query-limits.js';
2
2
  import SimfinityError from './errors/simfinity.error.js';
3
+ import { QUERY_OPERATORS, textQueryOperator, validateTextFilterValue } from './query-operators.js';
3
4
 
4
5
  const fail = (message, code = 'INVALID_FILTER_VALUE') => { throw new SimfinityError(message, code, 400); };
5
- const operators = new Set(['EQ', 'NE', 'LT', 'LTE', 'GT', 'GTE', 'BTW', 'IN', 'NIN', 'LIKE']);
6
+ const operators = new Set(QUERY_OPERATORS);
6
7
  const reserved = new Set(['AND', 'OR', 'sort', 'pagination', 'aggregation']);
7
8
  // `aggregation` is a generated argument only on aggregate endpoints; on find and count a field of
8
9
  // that name is filtered like any other.
@@ -47,6 +48,40 @@ export const collectQueryPathEntries = (args, operation = 'find') => {
47
48
  return entries;
48
49
  };
49
50
 
51
+ /**
52
+ * Internal: the operator of every condition the arguments hold, as `{ operator, path }` entries: field
53
+ * filters, relation terms and AND/OR group conditions at any depth, with an omitted operator read as
54
+ * `EQ`. `path` names the condition for messages. Sort, pagination and aggregation hold no operators.
55
+ * Malformed entries are skipped and left to the backends; a group reached twice is read once.
56
+ */
57
+ export const collectQueryOperators = (args, operation = 'find') => {
58
+ const entries = [];
59
+ if (!args || typeof args !== 'object') return entries;
60
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
61
+ const add = (item, path) => {
62
+ if (isObject(item)) entries.push({ operator: item.operator ?? 'EQ', path });
63
+ };
64
+ const visited = new Set();
65
+ const visit = (group) => {
66
+ if (!isObject(group) || visited.has(group)) return;
67
+ visited.add(group);
68
+ for (const condition of listOf(group.conditions)) {
69
+ add(condition, condition?.path != null && condition.path !== '' ? `${condition.field}.${condition.path}` : condition?.field);
70
+ }
71
+ for (const item of [...listOf(group.AND), ...listOf(group.OR)]) visit(item);
72
+ };
73
+ const controls = controlArgs(operation);
74
+ for (const [name, value] of Object.entries(args)) {
75
+ if (controls.has(name) || !isObject(value)) continue;
76
+ // A relation filter's operators are in its terms; an operator beside them is read too, since a
77
+ // backend that reads the field as a scalar would compile it.
78
+ if (!Array.isArray(value.terms) || value.operator !== undefined) add(value, name);
79
+ if (Array.isArray(value.terms)) for (const term of value.terms) add(term, `${name}.${term?.path}`);
80
+ }
81
+ for (const item of [...listOf(args.AND), ...listOf(args.OR)]) visit(item);
82
+ return entries;
83
+ };
84
+
50
85
  /** Internal: the segment arrays of {@link collectQueryPathEntries}. */
51
86
  export const collectQueryPaths = (args, operation = 'find') => (
52
87
  collectQueryPathEntries(args, operation).map((entry) => entry.segments)
@@ -87,8 +122,14 @@ export const resolveModelPath = (models, entityName, path) => {
87
122
  return steps;
88
123
  };
89
124
 
90
- /** Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. */
91
- export const createQueryPlan = (models, entityName, input = {}, { mode = 'find' } = {}) => {
125
+ /**
126
+ * Typed paths and logical groups; neither SQL nor Mongo pipeline objects are stored here. Operators
127
+ * and values are validated here, text operators through validateTextFilterValue. With `operators`,
128
+ * the names a compiler supports, any other QLOperator value fails with UNSUPPORTED_QUERY (400);
129
+ * without it every QLOperator value is planned.
130
+ */
131
+ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find', operators: supported } = {}) => {
132
+ const allowed = supported == null ? null : new Set(supported);
92
133
  const combine = (kind, terms) => {
93
134
  const active = terms.filter(Boolean);
94
135
  return active.length > 1 ? { kind, terms: active } : active[0] || null;
@@ -99,11 +140,13 @@ export const createQueryPlan = (models, entityName, input = {}, { mode = 'find'
99
140
  if (field.kind !== 'scalar' && !field.inferred) fail(`Filter on object field ${path} requires a scalar path`, 'MISSING_FILTER_PATH');
100
141
  const op = operator ?? 'EQ';
101
142
  if (!operators.has(op)) fail(`Unknown filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
143
+ if (allowed && !allowed.has(op)) fail(`The ${op} filter operator is not supported by this query compiler`, 'UNSUPPORTED_QUERY');
102
144
  const collection = ['IN', 'NIN', 'BTW'].includes(op);
103
145
  if (value === undefined || (!collection && Array.isArray(value)) || (value === null && !['EQ', 'NE'].includes(op))) fail('Expected a scalar filter value');
104
146
  if (collection && Array.isArray(value) && value.some((item) => item == null || Array.isArray(item))) fail('Filter lists require non-null scalar elements');
105
147
  if (['IN', 'NIN', 'BTW'].includes(op) && !Array.isArray(value)) fail(`${op} requires an array value`);
106
148
  if (op === 'BTW' && value.length !== 2) fail('BTW requires two values');
149
+ if (textQueryOperator(op)) validateTextFilterValue(op, value, field.scalar, path);
107
150
  return { kind: 'predicate', path: Array.isArray(path) ? path : path.split('.'), operator: op, value: value === undefined ? null : value };
108
151
  };
109
152
  const validateGroup = (value, keys = ['conditions', 'AND', 'OR']) => {
@@ -1,8 +1,11 @@
1
1
  import { GraphQLList, GraphQLNonNull } from 'graphql';
2
+ import SimfinityError from './errors/simfinity.error.js';
2
3
 
3
- // Internal helpers that decide where a relation stores its link and which types keep a stored
4
- // identity. They are shared by the core runtime and the MongoDB adapter through the
5
- // `./internal/relation-storage` subpath, which is not public API.
4
+ // Internal helpers that decide where a relation stores its link, which types keep a stored
5
+ // identity and which fields store nothing (virtual fields). They are shared by the core runtime and
6
+ // the other packages through the `./internal/relation-storage` subpath, which is not public API.
7
+ // isVirtualField and virtualFieldOptions are also public, as root exports of the core package, for
8
+ // custom adapters that build storage from GraphQL fields.
6
9
 
7
10
  const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
8
11
 
@@ -18,6 +21,53 @@ export const getListShape = (type) => {
18
21
  };
19
22
  };
20
23
 
24
+ export const VIRTUAL_MCP_MODES = Object.freeze(['tool', 'select', false]);
25
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
27
+
28
+ // A virtual field (`extensions.virtual`) is resolved by its own resolver and stored nowhere: it has
29
+ // no storage, no generated input and no query path. Only `true` and a plain options object with
30
+ // known options declare one, and this returns its normalized options, `{ mcp }`, with `mcp`
31
+ // defaulting to 'tool'; it returns null for any other field. createSchema() and describeModels()
32
+ // reject other `virtual` values with INVALID_MODEL, so in their schemas a field with
33
+ // `extensions.virtual` is always virtual.
34
+ export const virtualFieldOptions = (field) => {
35
+ const virtual = field?.extensions?.virtual;
36
+ if (virtual === true) return { mcp: 'tool' };
37
+ if (!isPlainObject(virtual) || Object.keys(virtual).some((key) => key !== 'mcp')) return null;
38
+ if (virtual.mcp !== undefined && !VIRTUAL_MCP_MODES.includes(virtual.mcp)) return null;
39
+ return { mcp: virtual.mcp ?? 'tool' };
40
+ };
41
+
42
+ export const isVirtualField = (field) => virtualFieldOptions(field) !== null;
43
+
44
+ // A relation's connectionField is a field of the record that stores the link: the reference's own
45
+ // type, or a collection's item type. It is a field name, never a path into an embedded value, which
46
+ // would put the link inside another value: MongoDB would store it in that value's member and read it
47
+ // back as missing, and PostgreSQL would name a column after the whole path. A link that belongs to an
48
+ // embedded value is declared there, as a reference of the embedded type. Throws INVALID_MODEL (400)
49
+ // for a connectionField that is not a string, or that contains a dot; an omitted or empty one keeps
50
+ // its existing meaning.
51
+ export const assertPlainConnectionField = (typeName, fieldName, field) => {
52
+ const relation = field?.extensions?.relation;
53
+ if (!relation || relation.embedded) return;
54
+ const { connectionField } = relation;
55
+ if (connectionField === undefined || connectionField === null || connectionField === '') return;
56
+ const name = `${typeName}.${fieldName}`;
57
+ const listShape = getListShape(field.type);
58
+ const holder = listShape ? listShape.itemType.name : typeName;
59
+ if (typeof connectionField !== 'string') {
60
+ throw new SimfinityError(`${name} extensions.relation.connectionField must be the name of a field of ${holder}`,
61
+ 'INVALID_MODEL', 400);
62
+ }
63
+ if (connectionField.includes('.')) {
64
+ throw new SimfinityError(`${name} extensions.relation.connectionField ${connectionField} must name a field of ${holder}, `
65
+ + 'not a path into an embedded value'
66
+ + (listShape ? '' : '; declare a reference that belongs to an embedded value inside the embedded type'),
67
+ 'INVALID_MODEL', 400);
68
+ }
69
+ };
70
+
21
71
  export const getFieldStorageName = (fieldName, field) => {
22
72
  const relation = field.extensions?.relation;
23
73
  return relation && !relation.embedded && !getListShape(field.type)
package/src/runtime.js CHANGED
@@ -13,12 +13,18 @@ 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';
17
20
  import { relationFieldOwners } from './relation-owners.js';
18
21
  import { isThenable } from './auth/thenable.js';
19
22
  import {
20
- getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
23
+ assertPlainConnectionField, getFieldStorageName, getListShape, isVirtualField, markStoredIdentity, normalizeConnectionField,
21
24
  } from './relation-storage.js';
25
+ import {
26
+ assertVirtualConnection, assertVirtualDeclaration, assertVirtualIndexes, assertVirtualStateField, virtualConflicts,
27
+ } from './virtual-fields.js';
22
28
  import {
23
29
  fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
24
30
  } from './introspection.js';
@@ -71,6 +77,8 @@ const hasApplicationResolver = (gqltype, fieldName) => (
71
77
  // so a bound type cannot be registered in, or reached from, another runtime's schema.
72
78
  const runtimeBoundTypes = new WeakMap();
73
79
  const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
80
+ // Generated inputs never write a readOnly field, nor a virtual one, which virtual implies.
81
+ const isReadOnlyField = (field) => !!field.extensions?.readOnly || isVirtualField(field);
74
82
 
75
83
  // Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
76
84
  const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
@@ -446,6 +454,10 @@ export const createRuntime = (adapter) => {
446
454
  if (!adapter) {
447
455
  throw new SimfinityError('A database adapter is required', 'ADAPTER_REQUIRED', 500);
448
456
  }
457
+ // The filter operators the adapter compiles, read once: its queryFeatures.operators, or the 3.7.0
458
+ // operators when it declares none.
459
+ const supportedOperators = resolveQueryFeatures(adapter.queryFeatures).operators;
460
+ const supportsEveryOperator = QUERY_OPERATORS.every((operator) => supportedOperators.has(operator));
449
461
 
450
462
  const stateValue = adapter.stateValue || ((state) => state.name);
451
463
  const runtimeIdentity = {};
@@ -816,7 +828,7 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
816
828
  const selfCollectionFieldNames = (gqltype) => {
817
829
  const hasStateMachine = !!typesDict.types[gqltype.name]?.stateMachine;
818
830
  return Object.entries(gqltype.getFields())
819
- .filter(([fieldName, field]) => !field.extensions?.readOnly
831
+ .filter(([fieldName, field]) => !isReadOnlyField(field)
820
832
  && !(fieldName === 'state' && hasStateMachine)
821
833
  && getListShape(field.type)?.itemType === gqltype)
822
834
  .map(([fieldName]) => fieldName);
@@ -896,7 +908,7 @@ const buildInputType = (gqltype, reachable) => {
896
908
  const fieldArg = {};
897
909
  const fieldArgForUpdate = {};
898
910
 
899
- if (!fieldEntry.extensions || !fieldEntry.extensions.readOnly) {
911
+ if (!isReadOnlyField(fieldEntry)) {
900
912
  const hasStateMachine = !!typesDict.types[gqltype.name].stateMachine;
901
913
  const stateFieldManagedByStateMachine = !!(fieldEntryName === 'state' && hasStateMachine);
902
914
 
@@ -1037,7 +1049,7 @@ const typesWithReachableInputs = (includedMutationTypes) => {
1037
1049
  if (reached.has(gqltype)) return;
1038
1050
  reached.add(gqltype);
1039
1051
  for (const field of Object.values(gqltype.getFields())) {
1040
- if (field.extensions?.readOnly) continue;
1052
+ if (isReadOnlyField(field)) continue;
1041
1053
  const listShape = getListShape(field.type);
1042
1054
  const target = listShape ? listShape.itemType : unwrapNonNull(field.type);
1043
1055
  // A single reference is an IdInputType, so it reaches no input of its target.
@@ -1181,6 +1193,8 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1181
1193
  const collectionFields = {};
1182
1194
 
1183
1195
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1196
+ // A virtual field stores nothing, whatever middleware or saveObject() passes for it.
1197
+ if (isVirtualField(fieldEntry)) continue;
1184
1198
  const fieldValue = ownValue(args, fieldEntryName);
1185
1199
  if (fieldEntry.extensions && fieldEntry.extensions.validations
1186
1200
  && fieldEntry.extensions.validations[operation]) {
@@ -1339,7 +1353,7 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
1339
1353
  for (const [memberName, member] of Object.entries(embeddedType.getFields())) {
1340
1354
  const relation = member.extensions?.relation;
1341
1355
  const memberList = getListShape(member.type);
1342
- if (relation && !relation.embedded && memberList) continue;
1356
+ if ((relation && !relation.embedded && memberList) || isVirtualField(member)) continue;
1343
1357
  const storageName = getFieldStorageName(memberName, member);
1344
1358
  if (ownValue(item, storageName) === undefined && memberList) item[storageName] = [];
1345
1359
  if (memberName === 'id' || member.extensions?.readOnly) continue;
@@ -1366,7 +1380,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
1366
1380
  const members = unwrapNonNull(fieldEntry.type).getFields();
1367
1381
  for (const [memberName, value] of Object.entries(patch)) {
1368
1382
  const member = members[memberName];
1369
- if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
1383
+ if (value !== null || !member || member.type instanceof GraphQLNonNull || isVirtualField(member)) continue;
1370
1384
  const relation = member.extensions?.relation;
1371
1385
  const listShape = getListShape(member.type);
1372
1386
  if (relation && !relation.embedded && listShape) continue;
@@ -1429,7 +1443,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1429
1443
  }
1430
1444
 
1431
1445
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1432
- if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
1446
+ if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)) {
1433
1447
  materializedModel.modelArgs.$unset = {
1434
1448
  ...materializedModel.modelArgs.$unset,
1435
1449
  [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
@@ -1735,6 +1749,9 @@ const inspectClientPaths = (gqltype, args, operation) => {
1735
1749
  for (const { segments, group } of collectQueryPathEntries(args, operation)) {
1736
1750
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1737
1751
  const path = segments.slice(0, index + 1).join('.');
1752
+ if (isVirtualField(field)) {
1753
+ throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
1754
+ }
1738
1755
  const queryable = field.extensions?.queryable;
1739
1756
  if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
1740
1757
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
@@ -1759,6 +1776,39 @@ const inspectClientPaths = (gqltype, args, operation) => {
1759
1776
  return joinedScopes;
1760
1777
  };
1761
1778
 
1779
+ // Whether a schema of this runtime declared a virtual field, so that query paths may name one.
1780
+ let virtualFieldsDeclared = false;
1781
+
1782
+ // Client paths through a virtual field were rejected before middleware and scopes ran
1783
+ // (inspectClientPaths), so a path through one now is a trusted path that middleware or a scope added.
1784
+ // It would compile against storage that does not exist, so the operation fails with INVALID_MODEL
1785
+ // (500), the code of model declarations that cannot be honored at request time.
1786
+ const assertStoredQueryPaths = (gqltype, args, operation) => {
1787
+ if (!virtualFieldsDeclared) return;
1788
+ for (const { segments } of collectQueryPathEntries(args, operation)) {
1789
+ walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1790
+ if (!isVirtualField(field)) return;
1791
+ throw new SimfinityError(`Query path ${segments.slice(0, index + 1).join('.')}, added by middleware or a `
1792
+ + `scope, uses the virtual field ${type.name}.${fieldName}, which is not stored`, 'INVALID_MODEL', 500);
1793
+ });
1794
+ }
1795
+ };
1796
+
1797
+ // 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);
1808
+ }
1809
+ }
1810
+ };
1811
+
1762
1812
  // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
1763
1813
  // reach a non-embedded relation inside an embedded list, which is joined once per list item.
1764
1814
  const assertJoinedScopePaths = (groups, prefix, target) => {
@@ -1912,6 +1962,8 @@ const resolveById = async (type, args, context, requiredId) => {
1912
1962
  }
1913
1963
  const queryArgs = { id: { operator: 'EQ', value: args.id } };
1914
1964
  await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
1965
+ assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
1966
+ assertSupportedOperators(type.gqltype, queryArgs, 'find');
1915
1967
  const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
1916
1968
  return results[0] || null;
1917
1969
  };
@@ -2419,6 +2471,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2419
2471
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'find', countSink: true } },
2420
2472
  async resolve(parent, args, context, info) {
2421
2473
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'find');
2474
+ assertSupportedOperators(type.gqltype, args, 'find');
2422
2475
  const params = {
2423
2476
  type,
2424
2477
  args,
@@ -2428,6 +2481,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2428
2481
  await executeMiddleware(params);
2429
2482
  await executeScope(params);
2430
2483
  await applyJoinedScopes(joinedScopes, params);
2484
+ assertStoredQueryPaths(type.gqltype, params.args, 'find');
2485
+ assertSupportedOperators(type.gqltype, params.args, 'find');
2431
2486
  const queryArgs = params.args;
2432
2487
  const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2433
2488
  && canReportCount(parent, info, context);
@@ -2448,7 +2503,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2448
2503
  };
2449
2504
 
2450
2505
  const aggregateArgsObject = { ...argsObject };
2451
- if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
2506
+ const fields = type.gqltype.getFields();
2507
+ if (Object.hasOwn(fields, 'aggregation') && !isVirtualField(fields.aggregation)) {
2452
2508
  warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
2453
2509
  + `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
2454
2510
  + 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
@@ -2463,6 +2519,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2463
2519
  extensions: { simfinityQuery: { typeName: type.gqltype.name, operation: 'aggregate' } },
2464
2520
  async resolve(parent, args, context) {
2465
2521
  const joinedScopes = inspectClientPaths(type.gqltype, args, 'aggregate');
2522
+ assertSupportedOperators(type.gqltype, args, 'aggregate');
2466
2523
  const params = {
2467
2524
  type,
2468
2525
  args,
@@ -2472,6 +2529,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2472
2529
  await executeMiddleware(params);
2473
2530
  await executeScope(params);
2474
2531
  await applyJoinedScopes(joinedScopes, params);
2532
+ assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
2533
+ assertSupportedOperators(type.gqltype, params.args, 'aggregate');
2475
2534
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2476
2535
  },
2477
2536
  };
@@ -2576,7 +2635,7 @@ const holdsNoData = (stored, type, ancestors = []) => !ancestors.includes(stored
2576
2635
  // which buildInputType leaves out; nor do referenced collections or members with an application
2577
2636
  // resolver, which may compute a value.
2578
2637
  const countsAsRequired = (type, memberName, member) => member.type instanceof GraphQLNonNull
2579
- && !isReferencedCollection(member) && memberName !== 'id' && !member.extensions?.readOnly
2638
+ && !isReferencedCollection(member) && memberName !== 'id' && !isReadOnlyField(member)
2580
2639
  && !isAbstractOutputType(unwrapListAndNonNull(member.type)) && !hasApplicationResolver(type, memberName);
2581
2640
  const hasRequiredMember = (type) => Object.entries(type.getFields())
2582
2641
  .some(([memberName, member]) => countsAsRequired(type, memberName, member));
@@ -2671,6 +2730,23 @@ const warnOnce = (gqltype, key, message) => {
2671
2730
  console.warn(message);
2672
2731
  };
2673
2732
 
2733
+ // A connectionField names a field, never a path (assertPlainConnectionField), in every type a model
2734
+ // stores: the registered types and the embedded types they store, at any depth, whether or not an
2735
+ // embedded type is registered itself, as describeModels and MongoDB's generated models reach them.
2736
+ const assertPlainConnectionFields = () => {
2737
+ const visited = new Set();
2738
+ const visit = (gqltype) => {
2739
+ if (visited.has(gqltype)) return;
2740
+ visited.add(gqltype);
2741
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
2742
+ assertPlainConnectionField(gqltype.name, fieldName, field);
2743
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
2744
+ if (named instanceof GraphQLObjectType) visit(named);
2745
+ }
2746
+ };
2747
+ for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
2748
+ };
2749
+
2674
2750
  // A writable referenced collection without a child connectionField would store unlinked children
2675
2751
  // through generated nested writes and read none back, so it is rejected before anything is built.
2676
2752
  // One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
@@ -2691,6 +2767,50 @@ const assertCollectionConnections = () => {
2691
2767
  }
2692
2768
  };
2693
2769
 
2770
+ // Virtual fields (`extensions.virtual`) of the object types a schema reaches, registered or not, and
2771
+ // the uses of them that registered types declare, are checked before anything is built
2772
+ // (INVALID_MODEL, 400). Returns whether any field is virtual.
2773
+ const assertVirtualFields = (schemaTypes) => {
2774
+ let declared = false;
2775
+ for (const type of schemaTypes) {
2776
+ if (!(type instanceof GraphQLObjectType)) continue;
2777
+ for (const [fieldName, field] of Object.entries(type.getFields())) {
2778
+ if (assertVirtualDeclaration(type.name, fieldName, field, () => hasApplicationResolver(type, fieldName))) {
2779
+ declared = true;
2780
+ }
2781
+ assertVirtualConnection(type, fieldName, field, () => hasApplicationResolver(type, fieldName));
2782
+ }
2783
+ }
2784
+ for (const { gqltype, stateMachine } of Object.values(typesDict.types)) {
2785
+ assertVirtualIndexes(gqltype);
2786
+ assertVirtualStateField(gqltype, stateMachine);
2787
+ }
2788
+ return declared;
2789
+ };
2790
+
2791
+ // A readOnly field with arguments and its own resolver is almost always computed, yet unless it is
2792
+ // virtual it is stored and filterable like any other field. References and collections, whose
2793
+ // storage is a link rather than the field's value and which cannot be virtual, are not reported.
2794
+ // The advice also names the settings it declares that a virtual field cannot, such as the
2795
+ // `relation: { embedded: true }` of a computed embedded value.
2796
+ const warnUndeclaredVirtualFields = () => {
2797
+ for (const { gqltype } of Object.values(typesDict.types)) {
2798
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
2799
+ const relation = field.extensions?.relation;
2800
+ if (!field.extensions?.readOnly || !field.args.length || isVirtualField(field)
2801
+ || (relation && !relation.embedded) || !hasApplicationResolver(gqltype, fieldName)) continue;
2802
+ const conflicts = virtualConflicts(field.extensions);
2803
+ const removal = conflicts.length
2804
+ ? ` and remove ${conflicts.length < 3 ? conflicts.join(' and ') : `${conflicts.slice(0, -1).join(', ')} and ${conflicts.at(-1)}`}`
2805
+ : '';
2806
+ warnOnce(gqltype, `undeclaredVirtual:${fieldName}`, `Configuration issue: ${gqltype.name}.${fieldName} is `
2807
+ + 'readOnly and has arguments and its own resolver, but it is not virtual, so Simfinity describes it as '
2808
+ + 'stored data: backends may store it, and list queries get a filter argument for it. If its resolver '
2809
+ + `computes it, declare extensions: { virtual: true }${removal}.`);
2810
+ }
2811
+ }
2812
+ };
2813
+
2694
2814
  const getRegistrations = () => Object.values(typesDict.types);
2695
2815
 
2696
2816
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
@@ -2713,6 +2833,9 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2713
2833
  const schemaTypes = collectSchemaOutputTypes();
2714
2834
  schemaTypes.forEach(assertNotBoundElsewhere);
2715
2835
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
2836
+ if (assertVirtualFields(schemaTypes)) virtualFieldsDeclared = true;
2837
+ warnUndeclaredVirtualFields();
2838
+ assertPlainConnectionFields();
2716
2839
  assertCollectionConnections();
2717
2840
  // An application type with the name of a field metadata type that an earlier schema fixed; one that
2718
2841
  // only generated types or the fields of an input object reach is found when the schema is
@@ -2857,10 +2980,13 @@ const autoGenerateResolvers = (gqltype) => {
2857
2980
  throw new Error(`Related type ${relatedType.name} not found or not connected. Make sure it's connected with simfinity.connect() or simfinity.addNoEndpointType().`);
2858
2981
  }
2859
2982
  const joinedScopes = inspectClientPaths(relatedType, args, 'find');
2983
+ assertSupportedOperators(relatedType, args, 'find');
2860
2984
  const params = { type: relatedTypeInfo, args, operation: 'find', context };
2861
2985
  await executeMiddleware(params);
2862
2986
  await executeScope(params);
2863
2987
  await applyJoinedScopes(joinedScopes, params);
2988
+ assertStoredQueryPaths(relatedType, params.args, 'find');
2989
+ assertSupportedOperators(relatedType, params.args, 'find');
2864
2990
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
2865
2991
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
2866
2992
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
@@ -2961,11 +3087,12 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
2961
3087
 
2962
3088
  // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
2963
3089
  // A field named like a generated argument gets no top-level filter either; it is reported once.
3090
+ // A virtual field is not part of the query language, so it gets no filter at all.
2964
3091
  const createArgsForQuery = (gqltype, omit = null) => {
2965
3092
  const argsObject = {};
2966
3093
  const argTypes = gqltype.getFields();
2967
3094
  for (const name of LIST_QUERY_ARGS) {
2968
- if (name !== omit && Object.hasOwn(argTypes, name)) {
3095
+ if (name !== omit && Object.hasOwn(argTypes, name) && !isVirtualField(argTypes[name])) {
2969
3096
  warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
2970
3097
  + 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
2971
3098
  + 'filter it in AND/OR group conditions or rename the field.');
@@ -2973,7 +3100,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
2973
3100
  }
2974
3101
 
2975
3102
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2976
- if (fieldEntryName === omit) continue;
3103
+ if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
2977
3104
  let filterType = null;
2978
3105
 
2979
3106
  if (fieldEntry.type instanceof GraphQLScalarType
@@ -0,0 +1,135 @@
1
+ import { getNamedType } from 'graphql';
2
+ import SimfinityError from './errors/simfinity.error.js';
3
+ import { getListShape, isVirtualField, VIRTUAL_MCP_MODES } from './relation-storage.js';
4
+
5
+ // Internal checks of virtual field declarations (`extensions.virtual`), shared by createSchema() and
6
+ // describeModels(). A virtual field is resolved by its own resolver and stored nowhere, so every
7
+ // setting or use that needs storage, a generated input or a query path is rejected.
8
+
9
+ const invalid = (message) => {
10
+ throw new SimfinityError(message, 'INVALID_MODEL', 400);
11
+ };
12
+
13
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
14
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
15
+
16
+ // Settings that describe storage, writes or the query language, which a virtual field has none of.
17
+ // `readOnly: true` and `queryable: false` restate what virtual implies, so they are allowed.
18
+ const CONFLICTING_SETTINGS = [
19
+ ['relation', (value) => !!value, 'extensions.relation'],
20
+ ['unique', (value) => value === true, 'unique: true'],
21
+ ['validations', (value) => !!value, 'extensions.validations'],
22
+ ['queryable', (value) => value === true, 'queryable: true'],
23
+ ['readOnly', (value) => value === false, 'readOnly: false'],
24
+ ];
25
+
26
+ /**
27
+ * The settings of a field's extensions that a virtual field cannot declare, as its INVALID_MODEL message
28
+ * names them (such as 'extensions.relation'), in the order they are checked. Empty when it could be virtual.
29
+ */
30
+ export const virtualConflicts = (extensions = {}) => CONFLICTING_SETTINGS
31
+ .filter(([key, conflicts]) => conflicts(extensions[key])).map(([, , setting]) => setting);
32
+
33
+ const ownField = (type, name) => {
34
+ const fields = typeof type?.getFields === 'function' ? type.getFields() : null;
35
+ return fields && typeof name === 'string' && Object.hasOwn(fields, name) ? fields[name] : undefined;
36
+ };
37
+
38
+ // The virtual field that a stored path names or passes through, with the type that holds it, or null.
39
+ // A MongoDB connectionField or index key may be a dotted path into embedded values, such as
40
+ // `meta.key`, so each segment is a member of the embedded type that the previous segment holds. A
41
+ // path stops at a segment that is not a declared field or not an embedded value: storage then has
42
+ // no member to name there, and a reference stores only its own link, not its target's fields.
43
+ const virtualFieldOnPath = (type, path) => {
44
+ let holder = type;
45
+ const segments = path.split('.');
46
+ for (const [index, segment] of segments.entries()) {
47
+ const field = ownField(holder, segment);
48
+ if (!field) return null;
49
+ if (isVirtualField(field)) return { holder, name: segment };
50
+ if (index === segments.length - 1 || !field.extensions?.relation?.embedded) return null;
51
+ holder = getNamedType(field.type);
52
+ }
53
+ return null;
54
+ };
55
+
56
+ // ` (through meta.key)` for a dotted path, so the message names the path that was declared.
57
+ const throughPath = (path, found) => (path === found.name ? '' : ` (through ${path})`);
58
+
59
+ /**
60
+ * Checks the `extensions.virtual` declaration of `typeName.fieldName` and returns whether the field is
61
+ * virtual. `hasOwnResolver()` tells whether the field has its own resolver; it is called only for a
62
+ * field that declares `virtual`. Throws INVALID_MODEL (400), naming the type and field, for a value
63
+ * other than `true` or a plain options object, an unknown option, an `mcp` option other than 'tool',
64
+ * 'select' or false, a missing resolver, a conflicting setting, and an identity field (`id` or `_id`).
65
+ */
66
+ export const assertVirtualDeclaration = (typeName, fieldName, field, hasOwnResolver) => {
67
+ const extensions = field.extensions || {};
68
+ const { virtual } = extensions;
69
+ if (virtual === undefined) return false;
70
+ const name = `${typeName}.${fieldName}`;
71
+ if (virtual !== true) {
72
+ if (!isPlainObject(virtual)) invalid(`${name} extensions.virtual must be true or an options object such as { mcp: 'select' }`);
73
+ const unknown = Object.keys(virtual).find((key) => key !== 'mcp');
74
+ if (unknown !== undefined) invalid(`${name} extensions.virtual has the unknown option ${unknown}; its only option is mcp`);
75
+ if (virtual.mcp !== undefined && !VIRTUAL_MCP_MODES.includes(virtual.mcp)) {
76
+ invalid(`${name} extensions.virtual.mcp must be 'tool', 'select' or false`);
77
+ }
78
+ }
79
+ if (!hasOwnResolver()) invalid(`${name} is virtual, so it requires its own resolve function`);
80
+ const [setting] = virtualConflicts(extensions);
81
+ if (setting) invalid(`${name} is virtual, which is never stored, so it cannot declare ${setting}`);
82
+ if (fieldName === 'id' || fieldName === '_id') invalid(`${name} is the identity field, so it cannot be virtual`);
83
+ return true;
84
+ };
85
+
86
+ /**
87
+ * A reference stores its link under its connectionField, on its own type, and a collection reads its
88
+ * items by their type's connectionField, or by their own field name without one. Throws INVALID_MODEL
89
+ * (400) when that field is virtual, or when a dotted connectionField passes through or ends at a
90
+ * virtual member of an embedded type. `hasOwnResolver()` tells whether the application resolves the
91
+ * field itself, so that Simfinity never reads its items; it defaults to the field having a resolver.
92
+ */
93
+ export const assertVirtualConnection = (type, fieldName, field, hasOwnResolver = () => typeof field.resolve === 'function') => {
94
+ const relation = field.extensions?.relation;
95
+ if (!relation || relation.embedded) return;
96
+ const listShape = getListShape(field.type);
97
+ const holder = listShape ? listShape.itemType : type;
98
+ const { connectionField } = relation;
99
+ // Storage keys the link by the declared value as a property name, so a non-string value reaches
100
+ // the same path as its string form. A collection without one, whose items Simfinity reads, reads
101
+ // them by its own field name.
102
+ let path = null;
103
+ if (connectionField !== undefined && connectionField !== null && connectionField !== '') path = String(connectionField);
104
+ else if (listShape && !hasOwnResolver()) path = fieldName;
105
+ const found = path ? virtualFieldOnPath(holder, path) : null;
106
+ if (found) {
107
+ invalid(`${type.name}.${fieldName} uses the virtual field ${found.holder.name}.${found.name} as its connectionField`
108
+ + `${throughPath(path, found)}; a virtual field is never stored`);
109
+ }
110
+ };
111
+
112
+ /**
113
+ * Throws INVALID_MODEL (400) when an entry of the type's `extensions.indexes` names a virtual field, or a
114
+ * dotted key that passes through or ends at a virtual member of an embedded type.
115
+ */
116
+ export const assertVirtualIndexes = (type) => {
117
+ const indexes = type.extensions?.indexes;
118
+ if (!Array.isArray(indexes)) return;
119
+ for (const index of indexes) {
120
+ for (const name of Array.isArray(index?.fields) ? index.fields : []) {
121
+ const found = typeof name === 'string' ? virtualFieldOnPath(type, name) : null;
122
+ if (found) {
123
+ invalid(`An index of ${type.name} names the virtual field ${found.holder.name}.${found.name}`
124
+ + `${throughPath(name, found)}, which is never stored`);
125
+ }
126
+ }
127
+ }
128
+ };
129
+
130
+ /** Throws INVALID_MODEL (400) when the `state` field of a type with a state machine is virtual. */
131
+ export const assertVirtualStateField = (type, stateMachine) => {
132
+ if (stateMachine && isVirtualField(ownField(type, 'state'))) {
133
+ invalid(`${type.name}.state holds the state of its state machine, so it cannot be virtual`);
134
+ }
135
+ };
package/types/index.d.ts CHANGED
@@ -8,10 +8,36 @@ import type {
8
8
  GraphQLSchema,
9
9
  } from 'graphql';
10
10
 
11
+ /** The filter operator enum; its values are the {@link QueryOperator} names. */
11
12
  export const QLOperator: GraphQLEnumType;
12
13
  export const QLSort: GraphQLInputObjectType;
13
14
  export const QLValue: GraphQLScalarType<unknown, unknown>;
14
15
 
16
+ /**
17
+ * A `QLOperator` value. The operators of 3.7.0 are `EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `BTW`, `IN`,
18
+ * `NIN` and `LIKE`. The literal text operators take a non-null string without U+0000 on a `String`
19
+ * field or `String` list (a validated scalar filters by its base scalar), and match it literally:
20
+ * `ILIKE` as a substring ignoring case, `STARTS_WITH` and `ISTARTS_WITH` as a prefix, `ENDS_WITH`
21
+ * and `IENDS_WITH` as a suffix, respecting or ignoring case, and `IEQ` as the whole value ignoring
22
+ * case. An empty value matches every non-null string, except for `IEQ`, where it matches only the
23
+ * empty string. On a list, some element must match.
24
+ */
25
+ export type QueryOperator =
26
+ | 'EQ' | 'NE' | 'LT' | 'LTE' | 'GT' | 'GTE' | 'BTW' | 'IN' | 'NIN' | 'LIKE'
27
+ | 'ILIKE' | 'STARTS_WITH' | 'ISTARTS_WITH' | 'ENDS_WITH' | 'IENDS_WITH' | 'IEQ';
28
+
29
+ /**
30
+ * The query language features a {@link DatabaseAdapter} compiles. Declare it so that the runtime
31
+ * sends the adapter only conditions it compiles; later releases add members for later features.
32
+ */
33
+ export interface QueryFeatures {
34
+ /**
35
+ * The `QLOperator` values the adapter compiles, including an omitted operator's `EQ`. Names this
36
+ * version of Simfinity does not know are ignored.
37
+ */
38
+ operators: readonly (QueryOperator | (string & {}))[];
39
+ }
40
+
15
41
  export interface ModelRegistration {
16
42
  gqltype: GraphQLObjectType;
17
43
  /** Defaults to true. False excludes embedded-only types until referenced. */
@@ -43,7 +69,65 @@ export interface ModelDescription {
43
69
  indexes: Array<{ fields: string[]; unique: boolean }>;
44
70
  }>;
45
71
  }
72
+ /**
73
+ * Describes the stored data of the registered entities and the embedded types they reach. Virtual
74
+ * fields are left out of every entity and embedded type, and the types only they reach are not
75
+ * described. Throws `INVALID_MODEL` (400) for an invalid `extensions.virtual` declaration, as
76
+ * `createSchema()` does (see {@link VirtualFieldOptions}).
77
+ */
46
78
  export function describeModels(registrations: ModelRegistration[]): ModelDescription;
79
+ /**
80
+ * Options of a virtual field. A field declares itself virtual with `extensions: { virtual: true }` or
81
+ * `extensions: { virtual: { mcp } }` (see {@link VirtualFieldDeclaration}), and must have its own
82
+ * `resolve`, which receives the record, the field's arguments and the request context. No backend
83
+ * stores it, generated inputs leave it out, and it is not part of the query language: list,
84
+ * aggregate and collection fields have no filter argument for it, a client filter, sort, `groupId`
85
+ * or fact path through it fails with `FORBIDDEN_FILTER_PATH` (403), and a path that middleware or a
86
+ * scope adds through it fails with `INVALID_MODEL` (500). Virtual implies `readOnly`, and
87
+ * introspection reads `extensions { readOnly }` as true for it. Any output type may be its
88
+ * result, registered or not; a registered type needs no `relation`, and gets no reference or
89
+ * collection storage and no inferred inverse reference.
90
+ *
91
+ * `createSchema()` and `describeModels()` throw `INVALID_MODEL` (400), naming the type and field, for
92
+ * a value other than `true` or a plain options object, an unknown option, an invalid `mcp` value, a
93
+ * field without its own `resolve`, a field that also declares `relation`, `unique: true`,
94
+ * `validations`, `queryable: true` or `readOnly: false`, an identity field (`id` or `_id`), and for a
95
+ * virtual field named in the type's `extensions.indexes`, used as the `state` field of its state
96
+ * machine, or used as the `connectionField` of a reference or a collection.
97
+ */
98
+ export interface VirtualFieldOptions {
99
+ /**
100
+ * How generated MCP tools read the field. `'tool'`, the default, generates a
101
+ * `<byIdQuery>_<field>` read tool for a field with a required argument on a type with a generated
102
+ * by-ID query. `'select'` also lets tools select a field without required arguments below a list
103
+ * position, as in the records of list tools and the items of nested collections, where each item
104
+ * runs its resolver; elsewhere, such as at the top of by-ID results, tools always select it. A
105
+ * field with a required argument still gets its tool. `false` generates no per-field tool, and
106
+ * changes nothing else: by-ID and mutation tools still select a field without required
107
+ * arguments, so for such a field `'tool'` and `false` behave the same.
108
+ */
109
+ mcp?: 'tool' | 'select' | false;
110
+ }
111
+ /** The value of a virtual field's `extensions.virtual`. */
112
+ export type VirtualFieldDeclaration = true | VirtualFieldOptions;
113
+ /** A GraphQL field, or its configuration, as far as the virtual field helpers read it. */
114
+ export interface VirtualFieldCandidate {
115
+ readonly extensions?: Readonly<{ [name: string]: unknown }> | null;
116
+ }
117
+ /**
118
+ * Whether a field is virtual: its `extensions.virtual` is `true`, or a plain object whose only key is
119
+ * a valid `mcp`. The GraphQL types that `DatabaseAdapter.prepare()` and `createModel()` receive still
120
+ * contain virtual fields, so an adapter that builds storage from a type's fields skips the fields for
121
+ * which this returns true, on entities and embedded types alike. In a schema that `createSchema()`
122
+ * accepted, every field that declares `extensions.virtual` (other than `undefined`) is virtual,
123
+ * because it rejects every other value.
124
+ */
125
+ export function isVirtualField(field: VirtualFieldCandidate | null | undefined): boolean;
126
+ /**
127
+ * The normalized options of a virtual field, `{ mcp }` with `mcp` defaulting to `'tool'`, or null
128
+ * when the field is not virtual (see {@link isVirtualField}).
129
+ */
130
+ export function virtualFieldOptions(field: VirtualFieldCandidate | null | undefined): Required<VirtualFieldOptions> | null;
47
131
  /**
48
132
  * Creates the scalar `${name}_${baseScalarType.name}`. `validate` throws to reject a value and
49
133
  * receives the base scalar's internal value: the base's `parseValue` result for variables, the
@@ -222,7 +306,10 @@ export interface Runtime<Model = any, Session = any> {
222
306
  * non-empty child `connectionField`, whose resolver this runtime would generate, throws
223
307
  * `INVALID_MODEL` (400) before `adapter.prepare()`; one with an application resolver or
224
308
  * `readOnly` only logs a `Configuration issue` warning, and its nested writes fail with
225
- * `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. The
309
+ * `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. Invalid
310
+ * virtual field declarations throw `INVALID_MODEL` (400) before `adapter.prepare()` (see
311
+ * {@link VirtualFieldOptions}), and a `readOnly` field with arguments and its own resolver that is
312
+ * not virtual, and is not a reference or collection, logs a `Configuration issue` warning once. The
226
313
  * first schema binds the types whose relation resolvers it generates to this runtime and
227
314
  * reserves reachable types that still have a
228
315
  * non-embedded relation field without a resolver, such as an unregistered custom mutation result.
@@ -270,9 +357,30 @@ export interface Runtime<Model = any, Session = any> {
270
357
 
271
358
  /** Driver adapter contract. GraphQL generation and lifecycle live in createRuntime. */
272
359
  export interface DatabaseAdapter<Model = any, Session = any> {
360
+ /**
361
+ * Optional. The filter operators this adapter compiles. `createRuntime` reads it once and throws
362
+ * `INVALID_QUERY_FEATURES` (500) unless it is undefined, null or an object whose `operators` is an
363
+ * array of strings. Without it the adapter supports the operators of 3.7.0 (`EQ`, `NE`, `LT`,
364
+ * `LTE`, `GT`, `GTE`, `BTW`, `IN`, `NIN` and `LIKE`). A list, aggregate or collection read, or a
365
+ * by-ID read through a `get_by_id` scope, whose arguments use a `QLOperator` value the adapter
366
+ * does not support fails with `UNSUPPORTED_QUERY` (400), before middleware runs for the client's
367
+ * arguments and before `find`, `count`, `aggregate`, `findChildren` or `findChildrenBatch` for
368
+ * conditions that middleware, scopes and joined scopes added. Operators outside `QLOperator` still
369
+ * reach the adapter, which reports them as before.
370
+ */
371
+ queryFeatures?: QueryFeatures | null | undefined;
273
372
  bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
274
373
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
374
+ /**
375
+ * Optional. Receives every registration before models are created. Their GraphQL types, like the
376
+ * type that `createModel()` receives, still contain virtual fields (`extensions.virtual`), which
377
+ * must never be stored: an adapter that builds storage from a type's fields skips each field for
378
+ * which {@link isVirtualField} returns true, on the types its embedded fields reach as well, or
379
+ * builds it from `describeModels(registrations)`, which leaves them out. Generated writes, filters,
380
+ * sorts and aggregations never name a virtual field; a controller hook can still assign one.
381
+ */
275
382
  prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
383
+ /** Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. */
276
384
  createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
277
385
  /**
278
386
  * Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
@@ -296,8 +404,8 @@ export interface DatabaseAdapter<Model = any, Session = any> {
296
404
  * value unchanged. It must depend only on the value: these resolvers read no data and do not bind
297
405
  * their types, so a runtime that reaches a shared type may read through another runtime's hook.
298
406
  * Simfinity then reads as null an object that holds no data when a non-null member would read as
299
- * null, whatever the selection. These members do not count: `id`, readOnly members, interface and
300
- * union members, members with their own resolver and referenced collections. An object whose type
407
+ * null, whatever the selection. These members do not count: `id`, readOnly and virtual members,
408
+ * interface and union members, members with their own resolver and referenced collections. An object whose type
301
409
  * has no member that counts is returned untouched. Otherwise Simfinity reads the stored members
302
410
  * (see `rawEmbeddedValue`), never calling a getter, and counts a stored `_id` as `id`; an error
303
411
  * while reading them returns the object unchanged. Runtimes that share the type read through the
@@ -378,6 +486,7 @@ export interface DatabaseAdapter<Model = any, Session = any> {
378
486
  }
379
487
  export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapter<Model, Session>): Runtime<Model, Session>;
380
488
 
489
+ /** `operator` is a {@link QueryOperator}; the value of a text operator is a validated string. */
381
490
  export type QueryPredicate = { kind: 'predicate'; path: string[]; operator: string; value: any } | { kind: 'and' | 'or'; terms: QueryPredicate[] };
382
491
  export interface QueryPlan {
383
492
  entity: string;
@@ -387,7 +496,14 @@ export interface QueryPlan {
387
496
  pagination: { limit: number; offset: number } | null;
388
497
  aggregation?: { groupId: string[]; facts: Array<{ path: string[]; factName: string; operation: 'SUM' | 'COUNT' | 'AVG' | 'MIN' | 'MAX' }> };
389
498
  }
390
- export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode'] }): QueryPlan;
499
+ /**
500
+ * Validates the arguments into a plan: paths, operators and values, with `INVALID_FILTER_VALUE` (400)
501
+ * for a text operator on a field that is not a `String` field or list, or with a value that is not
502
+ * a string or contains U+0000. With `operators`, the names the caller compiles, any other
503
+ * `QLOperator` value fails with `UNSUPPORTED_QUERY` (400); unknown operators fail with
504
+ * `INVALID_FILTER_OPERATOR` (400) either way.
505
+ */
506
+ export function createQueryPlan(models: ModelDescription, entityName: string, input?: Record<string, any>, options?: { mode?: QueryPlan['mode']; operators?: Iterable<string> }): QueryPlan;
391
507
  export function resolveModelPath(models: ModelDescription, entityName: string, path: string | string[]): FieldDescription[];
392
508
 
393
509
  export function configureQueryLimits(options?: { maxPageSize?: number }): void;