@simtlix/simfinity-core 3.7.0 → 3.8.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,7 +16,7 @@ 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
 
@@ -24,18 +24,20 @@ Entity types, those registered with `connect()` and the targets of non-embedded
24
24
 
25
25
  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
26
 
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.
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. 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
28
 
29
29
  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
30
 
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.
31
+ 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
32
 
33
33
  ## Shared metadata and helpers
34
34
 
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.
35
+ `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
36
 
37
37
  `createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
38
38
 
39
+ `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.
40
+
39
41
  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
42
 
41
43
  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.8.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/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')) {
@@ -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
@@ -17,8 +17,11 @@ import { collectQueryPathEntries, collectQueryPaths, walkQueryPath } from './que
17
17
  import { relationFieldOwners } from './relation-owners.js';
18
18
  import { isThenable } from './auth/thenable.js';
19
19
  import {
20
- getFieldStorageName, getListShape, markStoredIdentity, normalizeConnectionField,
20
+ assertPlainConnectionField, getFieldStorageName, getListShape, isVirtualField, markStoredIdentity, normalizeConnectionField,
21
21
  } from './relation-storage.js';
22
+ import {
23
+ assertVirtualConnection, assertVirtualDeclaration, assertVirtualIndexes, assertVirtualStateField, virtualConflicts,
24
+ } from './virtual-fields.js';
22
25
  import {
23
26
  fieldMetadataNamesFixed, fieldMetadataTypes, fixFieldMetadataNames, renameFieldMetadataTypes,
24
27
  } from './introspection.js';
@@ -71,6 +74,8 @@ const hasApplicationResolver = (gqltype, fieldName) => (
71
74
  // so a bound type cannot be registered in, or reached from, another runtime's schema.
72
75
  const runtimeBoundTypes = new WeakMap();
73
76
  const forbiddenPath = (message) => new SimfinityError(message, 'FORBIDDEN_FILTER_PATH', 403);
77
+ // Generated inputs never write a readOnly field, nor a virtual one, which virtual implies.
78
+ const isReadOnlyField = (field) => !!field.extensions?.readOnly || isVirtualField(field);
74
79
 
75
80
  // Query scopes restrict reads, so a scope Simfinity cannot run must fail rather than be skipped.
76
81
  const SCOPE_OPERATIONS = ['find', 'get_by_id', 'aggregate'];
@@ -816,7 +821,7 @@ const graphQLListInputType = (dict, fieldEntry, fieldEntryName,
816
821
  const selfCollectionFieldNames = (gqltype) => {
817
822
  const hasStateMachine = !!typesDict.types[gqltype.name]?.stateMachine;
818
823
  return Object.entries(gqltype.getFields())
819
- .filter(([fieldName, field]) => !field.extensions?.readOnly
824
+ .filter(([fieldName, field]) => !isReadOnlyField(field)
820
825
  && !(fieldName === 'state' && hasStateMachine)
821
826
  && getListShape(field.type)?.itemType === gqltype)
822
827
  .map(([fieldName]) => fieldName);
@@ -896,7 +901,7 @@ const buildInputType = (gqltype, reachable) => {
896
901
  const fieldArg = {};
897
902
  const fieldArgForUpdate = {};
898
903
 
899
- if (!fieldEntry.extensions || !fieldEntry.extensions.readOnly) {
904
+ if (!isReadOnlyField(fieldEntry)) {
900
905
  const hasStateMachine = !!typesDict.types[gqltype.name].stateMachine;
901
906
  const stateFieldManagedByStateMachine = !!(fieldEntryName === 'state' && hasStateMachine);
902
907
 
@@ -1037,7 +1042,7 @@ const typesWithReachableInputs = (includedMutationTypes) => {
1037
1042
  if (reached.has(gqltype)) return;
1038
1043
  reached.add(gqltype);
1039
1044
  for (const field of Object.values(gqltype.getFields())) {
1040
- if (field.extensions?.readOnly) continue;
1045
+ if (isReadOnlyField(field)) continue;
1041
1046
  const listShape = getListShape(field.type);
1042
1047
  const target = listShape ? listShape.itemType : unwrapNonNull(field.type);
1043
1048
  // A single reference is an IdInputType, so it reaches no input of its target.
@@ -1181,6 +1186,8 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
1181
1186
  const collectionFields = {};
1182
1187
 
1183
1188
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1189
+ // A virtual field stores nothing, whatever middleware or saveObject() passes for it.
1190
+ if (isVirtualField(fieldEntry)) continue;
1184
1191
  const fieldValue = ownValue(args, fieldEntryName);
1185
1192
  if (fieldEntry.extensions && fieldEntry.extensions.validations
1186
1193
  && fieldEntry.extensions.validations[operation]) {
@@ -1339,7 +1346,7 @@ const completeEmbeddedValue = (fieldName, fieldEntry, value, patch = null) => {
1339
1346
  for (const [memberName, member] of Object.entries(embeddedType.getFields())) {
1340
1347
  const relation = member.extensions?.relation;
1341
1348
  const memberList = getListShape(member.type);
1342
- if (relation && !relation.embedded && memberList) continue;
1349
+ if ((relation && !relation.embedded && memberList) || isVirtualField(member)) continue;
1343
1350
  const storageName = getFieldStorageName(memberName, member);
1344
1351
  if (ownValue(item, storageName) === undefined && memberList) item[storageName] = [];
1345
1352
  if (memberName === 'id' || member.extensions?.readOnly) continue;
@@ -1366,7 +1373,7 @@ const clearNullEmbeddedMembers = (merged, fieldEntry, patch) => {
1366
1373
  const members = unwrapNonNull(fieldEntry.type).getFields();
1367
1374
  for (const [memberName, value] of Object.entries(patch)) {
1368
1375
  const member = members[memberName];
1369
- if (value !== null || !member || member.type instanceof GraphQLNonNull) continue;
1376
+ if (value !== null || !member || member.type instanceof GraphQLNonNull || isVirtualField(member)) continue;
1370
1377
  const relation = member.extensions?.relation;
1371
1378
  const listShape = getListShape(member.type);
1372
1379
  if (relation && !relation.embedded && listShape) continue;
@@ -1429,7 +1436,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
1429
1436
  }
1430
1437
 
1431
1438
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
1432
- if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull)) {
1439
+ if (args[fieldEntryName] === null && !(fieldEntry.type instanceof GraphQLNonNull) && !isVirtualField(fieldEntry)) {
1433
1440
  materializedModel.modelArgs.$unset = {
1434
1441
  ...materializedModel.modelArgs.$unset,
1435
1442
  [getFieldStorageName(fieldEntryName, fieldEntry)]: '',
@@ -1735,6 +1742,9 @@ const inspectClientPaths = (gqltype, args, operation) => {
1735
1742
  for (const { segments, group } of collectQueryPathEntries(args, operation)) {
1736
1743
  walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1737
1744
  const path = segments.slice(0, index + 1).join('.');
1745
+ if (isVirtualField(field)) {
1746
+ throw forbiddenPath(`Query path ${path} uses the virtual field ${type.name}.${fieldName}, which is not stored`);
1747
+ }
1738
1748
  const queryable = field.extensions?.queryable;
1739
1749
  if (queryable === false || (queryable !== true && hasApplicationResolver(type, fieldName))) {
1740
1750
  throw forbiddenPath(`Query path ${path} uses the non-queryable field ${type.name}.${fieldName}`);
@@ -1759,6 +1769,24 @@ const inspectClientPaths = (gqltype, args, operation) => {
1759
1769
  return joinedScopes;
1760
1770
  };
1761
1771
 
1772
+ // Whether a schema of this runtime declared a virtual field, so that query paths may name one.
1773
+ let virtualFieldsDeclared = false;
1774
+
1775
+ // Client paths through a virtual field were rejected before middleware and scopes ran
1776
+ // (inspectClientPaths), so a path through one now is a trusted path that middleware or a scope added.
1777
+ // It would compile against storage that does not exist, so the operation fails with INVALID_MODEL
1778
+ // (500), the code of model declarations that cannot be honored at request time.
1779
+ const assertStoredQueryPaths = (gqltype, args, operation) => {
1780
+ if (!virtualFieldsDeclared) return;
1781
+ for (const { segments } of collectQueryPathEntries(args, operation)) {
1782
+ walkQueryPath(gqltype, segments, ({ type, fieldName, field, index }) => {
1783
+ if (!isVirtualField(field)) return;
1784
+ throw new SimfinityError(`Query path ${segments.slice(0, index + 1).join('.')}, added by middleware or a `
1785
+ + `scope, uses the virtual field ${type.name}.${fieldName}, which is not stored`, 'INVALID_MODEL', 500);
1786
+ });
1787
+ }
1788
+ };
1789
+
1762
1790
  // A joined scope must not repeat the rows it restricts: its paths may not cross a collection, nor
1763
1791
  // reach a non-embedded relation inside an embedded list, which is joined once per list item.
1764
1792
  const assertJoinedScopePaths = (groups, prefix, target) => {
@@ -1912,6 +1940,7 @@ const resolveById = async (type, args, context, requiredId) => {
1912
1940
  }
1913
1941
  const queryArgs = { id: { operator: 'EQ', value: args.id } };
1914
1942
  await executeScope({ type, args: queryArgs, operation: 'get_by_id', context });
1943
+ assertStoredQueryPaths(type.gqltype, queryArgs, 'find');
1915
1944
  const results = await adapter.find(type.model, type.gqltype, queryArgs, null, { requiredId, context });
1916
1945
  return results[0] || null;
1917
1946
  };
@@ -2428,6 +2457,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2428
2457
  await executeMiddleware(params);
2429
2458
  await executeScope(params);
2430
2459
  await applyJoinedScopes(joinedScopes, params);
2460
+ assertStoredQueryPaths(type.gqltype, params.args, 'find');
2431
2461
  const queryArgs = params.args;
2432
2462
  const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2433
2463
  && canReportCount(parent, info, context);
@@ -2448,7 +2478,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2448
2478
  };
2449
2479
 
2450
2480
  const aggregateArgsObject = { ...argsObject };
2451
- if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
2481
+ const fields = type.gqltype.getFields();
2482
+ if (Object.hasOwn(fields, 'aggregation') && !isVirtualField(fields.aggregation)) {
2452
2483
  warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
2453
2484
  + `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
2454
2485
  + 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
@@ -2472,6 +2503,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2472
2503
  await executeMiddleware(params);
2473
2504
  await executeScope(params);
2474
2505
  await applyJoinedScopes(joinedScopes, params);
2506
+ assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
2475
2507
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2476
2508
  },
2477
2509
  };
@@ -2576,7 +2608,7 @@ const holdsNoData = (stored, type, ancestors = []) => !ancestors.includes(stored
2576
2608
  // which buildInputType leaves out; nor do referenced collections or members with an application
2577
2609
  // resolver, which may compute a value.
2578
2610
  const countsAsRequired = (type, memberName, member) => member.type instanceof GraphQLNonNull
2579
- && !isReferencedCollection(member) && memberName !== 'id' && !member.extensions?.readOnly
2611
+ && !isReferencedCollection(member) && memberName !== 'id' && !isReadOnlyField(member)
2580
2612
  && !isAbstractOutputType(unwrapListAndNonNull(member.type)) && !hasApplicationResolver(type, memberName);
2581
2613
  const hasRequiredMember = (type) => Object.entries(type.getFields())
2582
2614
  .some(([memberName, member]) => countsAsRequired(type, memberName, member));
@@ -2671,6 +2703,23 @@ const warnOnce = (gqltype, key, message) => {
2671
2703
  console.warn(message);
2672
2704
  };
2673
2705
 
2706
+ // A connectionField names a field, never a path (assertPlainConnectionField), in every type a model
2707
+ // stores: the registered types and the embedded types they store, at any depth, whether or not an
2708
+ // embedded type is registered itself, as describeModels and MongoDB's generated models reach them.
2709
+ const assertPlainConnectionFields = () => {
2710
+ const visited = new Set();
2711
+ const visit = (gqltype) => {
2712
+ if (visited.has(gqltype)) return;
2713
+ visited.add(gqltype);
2714
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
2715
+ assertPlainConnectionField(gqltype.name, fieldName, field);
2716
+ const named = field.extensions?.relation?.embedded && getNamedType(field.type);
2717
+ if (named instanceof GraphQLObjectType) visit(named);
2718
+ }
2719
+ };
2720
+ for (const { gqltype } of Object.values(typesDict.types)) visit(gqltype);
2721
+ };
2722
+
2674
2723
  // A writable referenced collection without a child connectionField would store unlinked children
2675
2724
  // through generated nested writes and read none back, so it is rejected before anything is built.
2676
2725
  // One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
@@ -2691,6 +2740,50 @@ const assertCollectionConnections = () => {
2691
2740
  }
2692
2741
  };
2693
2742
 
2743
+ // Virtual fields (`extensions.virtual`) of the object types a schema reaches, registered or not, and
2744
+ // the uses of them that registered types declare, are checked before anything is built
2745
+ // (INVALID_MODEL, 400). Returns whether any field is virtual.
2746
+ const assertVirtualFields = (schemaTypes) => {
2747
+ let declared = false;
2748
+ for (const type of schemaTypes) {
2749
+ if (!(type instanceof GraphQLObjectType)) continue;
2750
+ for (const [fieldName, field] of Object.entries(type.getFields())) {
2751
+ if (assertVirtualDeclaration(type.name, fieldName, field, () => hasApplicationResolver(type, fieldName))) {
2752
+ declared = true;
2753
+ }
2754
+ assertVirtualConnection(type, fieldName, field, () => hasApplicationResolver(type, fieldName));
2755
+ }
2756
+ }
2757
+ for (const { gqltype, stateMachine } of Object.values(typesDict.types)) {
2758
+ assertVirtualIndexes(gqltype);
2759
+ assertVirtualStateField(gqltype, stateMachine);
2760
+ }
2761
+ return declared;
2762
+ };
2763
+
2764
+ // A readOnly field with arguments and its own resolver is almost always computed, yet unless it is
2765
+ // virtual it is stored and filterable like any other field. References and collections, whose
2766
+ // storage is a link rather than the field's value and which cannot be virtual, are not reported.
2767
+ // The advice also names the settings it declares that a virtual field cannot, such as the
2768
+ // `relation: { embedded: true }` of a computed embedded value.
2769
+ const warnUndeclaredVirtualFields = () => {
2770
+ for (const { gqltype } of Object.values(typesDict.types)) {
2771
+ for (const [fieldName, field] of Object.entries(gqltype.getFields())) {
2772
+ const relation = field.extensions?.relation;
2773
+ if (!field.extensions?.readOnly || !field.args.length || isVirtualField(field)
2774
+ || (relation && !relation.embedded) || !hasApplicationResolver(gqltype, fieldName)) continue;
2775
+ const conflicts = virtualConflicts(field.extensions);
2776
+ const removal = conflicts.length
2777
+ ? ` and remove ${conflicts.length < 3 ? conflicts.join(' and ') : `${conflicts.slice(0, -1).join(', ')} and ${conflicts.at(-1)}`}`
2778
+ : '';
2779
+ warnOnce(gqltype, `undeclaredVirtual:${fieldName}`, `Configuration issue: ${gqltype.name}.${fieldName} is `
2780
+ + 'readOnly and has arguments and its own resolver, but it is not virtual, so Simfinity describes it as '
2781
+ + 'stored data: backends may store it, and list queries get a filter argument for it. If its resolver '
2782
+ + `computes it, declare extensions: { virtual: true }${removal}.`);
2783
+ }
2784
+ }
2785
+ };
2786
+
2694
2787
  const getRegistrations = () => Object.values(typesDict.types);
2695
2788
 
2696
2789
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
@@ -2713,6 +2806,9 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2713
2806
  const schemaTypes = collectSchemaOutputTypes();
2714
2807
  schemaTypes.forEach(assertNotBoundElsewhere);
2715
2808
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
2809
+ if (assertVirtualFields(schemaTypes)) virtualFieldsDeclared = true;
2810
+ warnUndeclaredVirtualFields();
2811
+ assertPlainConnectionFields();
2716
2812
  assertCollectionConnections();
2717
2813
  // An application type with the name of a field metadata type that an earlier schema fixed; one that
2718
2814
  // only generated types or the fields of an input object reach is found when the schema is
@@ -2861,6 +2957,7 @@ const autoGenerateResolvers = (gqltype) => {
2861
2957
  await executeMiddleware(params);
2862
2958
  await executeScope(params);
2863
2959
  await applyJoinedScopes(joinedScopes, params);
2960
+ assertStoredQueryPaths(relatedType, params.args, 'find');
2864
2961
  return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
2865
2962
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
2866
2963
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
@@ -2961,11 +3058,12 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
2961
3058
 
2962
3059
  // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
2963
3060
  // A field named like a generated argument gets no top-level filter either; it is reported once.
3061
+ // A virtual field is not part of the query language, so it gets no filter at all.
2964
3062
  const createArgsForQuery = (gqltype, omit = null) => {
2965
3063
  const argsObject = {};
2966
3064
  const argTypes = gqltype.getFields();
2967
3065
  for (const name of LIST_QUERY_ARGS) {
2968
- if (name !== omit && Object.hasOwn(argTypes, name)) {
3066
+ if (name !== omit && Object.hasOwn(argTypes, name) && !isVirtualField(argTypes[name])) {
2969
3067
  warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
2970
3068
  + 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
2971
3069
  + 'filter it in AND/OR group conditions or rename the field.');
@@ -2973,7 +3071,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
2973
3071
  }
2974
3072
 
2975
3073
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2976
- if (fieldEntryName === omit) continue;
3074
+ if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
2977
3075
  let filterType = null;
2978
3076
 
2979
3077
  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
@@ -43,7 +43,65 @@ export interface ModelDescription {
43
43
  indexes: Array<{ fields: string[]; unique: boolean }>;
44
44
  }>;
45
45
  }
46
+ /**
47
+ * Describes the stored data of the registered entities and the embedded types they reach. Virtual
48
+ * fields are left out of every entity and embedded type, and the types only they reach are not
49
+ * described. Throws `INVALID_MODEL` (400) for an invalid `extensions.virtual` declaration, as
50
+ * `createSchema()` does (see {@link VirtualFieldOptions}).
51
+ */
46
52
  export function describeModels(registrations: ModelRegistration[]): ModelDescription;
53
+ /**
54
+ * Options of a virtual field. A field declares itself virtual with `extensions: { virtual: true }` or
55
+ * `extensions: { virtual: { mcp } }` (see {@link VirtualFieldDeclaration}), and must have its own
56
+ * `resolve`, which receives the record, the field's arguments and the request context. No backend
57
+ * stores it, generated inputs leave it out, and it is not part of the query language: list,
58
+ * aggregate and collection fields have no filter argument for it, a client filter, sort, `groupId`
59
+ * or fact path through it fails with `FORBIDDEN_FILTER_PATH` (403), and a path that middleware or a
60
+ * scope adds through it fails with `INVALID_MODEL` (500). Virtual implies `readOnly`, and
61
+ * introspection reads `extensions { readOnly }` as true for it. Any output type may be its
62
+ * result, registered or not; a registered type needs no `relation`, and gets no reference or
63
+ * collection storage and no inferred inverse reference.
64
+ *
65
+ * `createSchema()` and `describeModels()` throw `INVALID_MODEL` (400), naming the type and field, for
66
+ * a value other than `true` or a plain options object, an unknown option, an invalid `mcp` value, a
67
+ * field without its own `resolve`, a field that also declares `relation`, `unique: true`,
68
+ * `validations`, `queryable: true` or `readOnly: false`, an identity field (`id` or `_id`), and for a
69
+ * virtual field named in the type's `extensions.indexes`, used as the `state` field of its state
70
+ * machine, or used as the `connectionField` of a reference or a collection.
71
+ */
72
+ export interface VirtualFieldOptions {
73
+ /**
74
+ * How generated MCP tools read the field. `'tool'`, the default, generates a
75
+ * `<byIdQuery>_<field>` read tool for a field with a required argument on a type with a generated
76
+ * by-ID query. `'select'` also lets tools select a field without required arguments below a list
77
+ * position, as in the records of list tools and the items of nested collections, where each item
78
+ * runs its resolver; elsewhere, such as at the top of by-ID results, tools always select it. A
79
+ * field with a required argument still gets its tool. `false` generates no per-field tool, and
80
+ * changes nothing else: by-ID and mutation tools still select a field without required
81
+ * arguments, so for such a field `'tool'` and `false` behave the same.
82
+ */
83
+ mcp?: 'tool' | 'select' | false;
84
+ }
85
+ /** The value of a virtual field's `extensions.virtual`. */
86
+ export type VirtualFieldDeclaration = true | VirtualFieldOptions;
87
+ /** A GraphQL field, or its configuration, as far as the virtual field helpers read it. */
88
+ export interface VirtualFieldCandidate {
89
+ readonly extensions?: Readonly<{ [name: string]: unknown }> | null;
90
+ }
91
+ /**
92
+ * Whether a field is virtual: its `extensions.virtual` is `true`, or a plain object whose only key is
93
+ * a valid `mcp`. The GraphQL types that `DatabaseAdapter.prepare()` and `createModel()` receive still
94
+ * contain virtual fields, so an adapter that builds storage from a type's fields skips the fields for
95
+ * which this returns true, on entities and embedded types alike. In a schema that `createSchema()`
96
+ * accepted, every field that declares `extensions.virtual` (other than `undefined`) is virtual,
97
+ * because it rejects every other value.
98
+ */
99
+ export function isVirtualField(field: VirtualFieldCandidate | null | undefined): boolean;
100
+ /**
101
+ * The normalized options of a virtual field, `{ mcp }` with `mcp` defaulting to `'tool'`, or null
102
+ * when the field is not virtual (see {@link isVirtualField}).
103
+ */
104
+ export function virtualFieldOptions(field: VirtualFieldCandidate | null | undefined): Required<VirtualFieldOptions> | null;
47
105
  /**
48
106
  * Creates the scalar `${name}_${baseScalarType.name}`. `validate` throws to reject a value and
49
107
  * receives the base scalar's internal value: the base's `parseValue` result for variables, the
@@ -222,7 +280,10 @@ export interface Runtime<Model = any, Session = any> {
222
280
  * non-empty child `connectionField`, whose resolver this runtime would generate, throws
223
281
  * `INVALID_MODEL` (400) before `adapter.prepare()`; one with an application resolver or
224
282
  * `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
283
+ * `INVALID_MODEL` (500). Fields named like generated query arguments also log a warning. Invalid
284
+ * virtual field declarations throw `INVALID_MODEL` (400) before `adapter.prepare()` (see
285
+ * {@link VirtualFieldOptions}), and a `readOnly` field with arguments and its own resolver that is
286
+ * not virtual, and is not a reference or collection, logs a `Configuration issue` warning once. The
226
287
  * first schema binds the types whose relation resolvers it generates to this runtime and
227
288
  * reserves reachable types that still have a
228
289
  * non-embedded relation field without a resolver, such as an unregistered custom mutation result.
@@ -272,7 +333,16 @@ export interface Runtime<Model = any, Session = any> {
272
333
  export interface DatabaseAdapter<Model = any, Session = any> {
273
334
  bind?(runtime: Pick<Runtime<Model, Session>, 'getModel' | 'getType' | 'getRegistrations'>): void;
274
335
  validateRegistration?(registration: RuntimeRegistration<Model, Session>): void;
336
+ /**
337
+ * Optional. Receives every registration before models are created. Their GraphQL types, like the
338
+ * type that `createModel()` receives, still contain virtual fields (`extensions.virtual`), which
339
+ * must never be stored: an adapter that builds storage from a type's fields skips each field for
340
+ * which {@link isVirtualField} returns true, on the types its embedded fields reach as well, or
341
+ * builds it from `describeModels(registrations)`, which leaves them out. Generated writes, filters,
342
+ * sorts and aggregations never name a virtual field; a controller hook can still assign one.
343
+ */
275
344
  prepare?(registrations: RuntimeRegistration<Model, Session>[], options: { createCollection: boolean }): void;
345
+ /** Creates the model of a registered type. Its fields include virtual fields; see `prepare()`. */
276
346
  createModel(type: GraphQLObjectType, callback: ((model: Model) => void) | null | undefined, options: { createCollection: boolean }): Model;
277
347
  /**
278
348
  * Casts a reference or batch key to the stored identifier type. Throw a `SimfinityError` with
@@ -296,8 +366,8 @@ export interface DatabaseAdapter<Model = any, Session = any> {
296
366
  * value unchanged. It must depend only on the value: these resolvers read no data and do not bind
297
367
  * their types, so a runtime that reaches a shared type may read through another runtime's hook.
298
368
  * 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
369
+ * null, whatever the selection. These members do not count: `id`, readOnly and virtual members,
370
+ * interface and union members, members with their own resolver and referenced collections. An object whose type
301
371
  * has no member that counts is returned untouched. Otherwise Simfinity reads the stored members
302
372
  * (see `rawEmbeddedValue`), never calling a getter, and counts a stored `_id` as `id`; an error
303
373
  * while reading them returns the object unchanged. Runtimes that share the type read through the