@simtlix/simfinity-core 3.6.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,24 +16,28 @@ 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
+
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.
20
22
 
21
23
  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()`.
22
24
 
23
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.
24
26
 
25
- 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.
26
28
 
27
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.
28
30
 
29
- 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.
30
32
 
31
33
  ## Shared metadata and helpers
32
34
 
33
- `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.
34
36
 
35
37
  `createQueryPlan` and `resolveModelPath` turn Simfinity filter, sort, pagination, and aggregation inputs into a driver-neutral plan resolved against that metadata.
36
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
+
37
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.
38
42
 
39
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.6.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,10 +1940,317 @@ 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
  };
1918
1947
 
1948
+ // Batched collection reads. A generated collection field reads its children with one
1949
+ // adapter.findChildren call per parent unless the adapter defines findChildrenBatch and its
1950
+ // batchesChildren predicate does not return false. Then, within one request (a context object), the
1951
+ // reads of one related type and connection storage field whose effective arguments (after
1952
+ // middleware, the scope and joined scopes) hold the same plain data wait until the promise jobs
1953
+ // queued so far have run, as references do, and are read together, at most CHILD_BATCH_SIZE
1954
+ // distinct parents per call.
1955
+ const CHILD_BATCH_SIZE = 100;
1956
+ // Arguments whose canonical key would exceed this many characters, such as a long IN list, read
1957
+ // alone, so a field never spends more than this on canonicalization.
1958
+ const CHILD_ARGS_KEY_BUDGET = 64 * 1024;
1959
+ const NOT_PLAIN = Symbol('not plain data');
1960
+
1961
+ // The canonical key of a collection read's effective arguments: two reads share it only when their
1962
+ // arguments hold the same data. Only plain data qualifies: strings, numbers (-0, NaN and Infinity
1963
+ // kept distinct), booleans, undefined, null, bigints, dates (by time, with no own properties), dense
1964
+ // arrays, and objects with the Object or a null prototype whose own string-keyed properties are
1965
+ // enumerable data properties, all writable and configurable, or all fixed by freezing. The key keeps
1966
+ // property order, null prototypes, frozen objects and shared references. Anything else, such as a
1967
+ // function, a symbol, a class instance (an ObjectId included), an accessor, a non-enumerable,
1968
+ // sealed or non-extensible member, a sparse or decorated array, a cycle, an exotic object that fails
1969
+ // while it is read, or a key over the budget, returns null, and the read runs alone.
1970
+ const childArgsKey = (args) => {
1971
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) return null;
1972
+ const parts = [];
1973
+ let size = 0;
1974
+ const push = (part) => {
1975
+ size += part.length;
1976
+ if (size > CHILD_ARGS_KEY_BUDGET) throw NOT_PLAIN;
1977
+ parts.push(part);
1978
+ };
1979
+ const seen = new Map();
1980
+ const visit = (value) => {
1981
+ switch (typeof value) {
1982
+ case 'string':
1983
+ if (value.length > CHILD_ARGS_KEY_BUDGET) throw NOT_PLAIN;
1984
+ push(JSON.stringify(value));
1985
+ return;
1986
+ case 'number': push(Object.is(value, -0) ? '-0' : String(value)); return;
1987
+ case 'boolean':
1988
+ case 'undefined': push(String(value)); return;
1989
+ case 'bigint': push(`${value}n`); return;
1990
+ case 'object': if (value !== null) break; push('null'); return;
1991
+ default: throw NOT_PLAIN;
1992
+ }
1993
+ const known = seen.get(value);
1994
+ if (known) {
1995
+ // A shared reference repeats the index of its first visit; a cycle is not plain data.
1996
+ if (!known.done) throw NOT_PLAIN;
1997
+ push(`@${known.index}`);
1998
+ return;
1999
+ }
2000
+ const entry = { index: seen.size, done: false };
2001
+ seen.set(value, entry);
2002
+ const prototype = Object.getPrototypeOf(value);
2003
+ const frozen = Object.isFrozen(value);
2004
+ const keys = Reflect.ownKeys(value);
2005
+ if (prototype === Date.prototype) {
2006
+ if (keys.length) throw NOT_PLAIN;
2007
+ // Throws a TypeError, caught below, for an object that only inherits from Date.prototype.
2008
+ push(`D${frozen ? 'F' : ''}${Date.prototype.getTime.call(value)}`);
2009
+ } else {
2010
+ const array = Array.isArray(value);
2011
+ if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw NOT_PLAIN;
2012
+ // A dense array owns its indices, in order, then its length, and nothing else.
2013
+ if (array && (keys.length !== value.length + 1
2014
+ || keys.some((key, index) => key !== (index < value.length ? String(index) : 'length'))
2015
+ || Reflect.getOwnPropertyDescriptor(value, 'length').writable === frozen)) throw NOT_PLAIN;
2016
+ if (!frozen && !Object.isExtensible(value)) throw NOT_PLAIN;
2017
+ push(`${array ? '[' : '{'}${prototype === null ? '0' : ''}${frozen ? 'F' : ''}`);
2018
+ for (const key of keys) {
2019
+ if (typeof key === 'symbol') throw NOT_PLAIN;
2020
+ if (array && key === 'length') continue;
2021
+ const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
2022
+ if (!descriptor || !('value' in descriptor) || !descriptor.enumerable
2023
+ || descriptor.writable === frozen || descriptor.configurable === frozen) throw NOT_PLAIN;
2024
+ if (!array) push(`${JSON.stringify(key)}:`);
2025
+ visit(descriptor.value);
2026
+ push(',');
2027
+ }
2028
+ push(array ? ']' : '}');
2029
+ }
2030
+ entry.done = true;
2031
+ };
2032
+ try {
2033
+ visit(args);
2034
+ return parts.join('');
2035
+ } catch {
2036
+ // Not plain data, too large, or an exotic object (such as a proxy) that failed while it was read.
2037
+ return null;
2038
+ }
2039
+ };
2040
+
2041
+ // A copy of arguments that childArgsKey accepted, with the same prototypes, frozen objects, dates
2042
+ // and shared references, so the adapter may change it without reaching the request's objects.
2043
+ const clonePlainArgs = (value, copies = new Map()) => {
2044
+ if (value === null || typeof value !== 'object') return value;
2045
+ if (copies.has(value)) return copies.get(value);
2046
+ const prototype = Object.getPrototypeOf(value);
2047
+ if (prototype === Date.prototype) {
2048
+ const date = new Date(Date.prototype.getTime.call(value));
2049
+ copies.set(value, date);
2050
+ return Object.isFrozen(value) ? Object.freeze(date) : date;
2051
+ }
2052
+ const copy = Array.isArray(value) ? [] : Object.create(prototype);
2053
+ copies.set(value, copy);
2054
+ for (const key of Object.keys(value)) {
2055
+ Object.defineProperty(copy, key, {
2056
+ value: clonePlainArgs(value[key], copies), writable: true, enumerable: true, configurable: true,
2057
+ });
2058
+ }
2059
+ return Object.isFrozen(value) ? Object.freeze(copy) : copy;
2060
+ };
2061
+
2062
+ // The batch key of a parent ID: its string form, when the adapter casts the ID without changing
2063
+ // that form. A missing ID (which keeps the adapter's own semantics, such as MongoDB matching
2064
+ // children without a parent), an object whose string form says nothing of its value, an ID castId
2065
+ // rejects and one it normalizes have none, and read alone.
2066
+ const childParentKey = (parentId) => {
2067
+ try {
2068
+ if (parentId == null) return null;
2069
+ const key = String(parentId);
2070
+ if (typeof parentId === 'object' && key === Object.prototype.toString.call(parentId)) return null;
2071
+ return String(adapter.castId(parentId)) === key ? key : null;
2072
+ } catch {
2073
+ return null;
2074
+ }
2075
+ };
2076
+
2077
+ // Collection reads of one request that wait for a batch: context -> related type registration ->
2078
+ // (connection storage field + canonical arguments) -> group. Nothing is cached: each group is read
2079
+ // once and dropped.
2080
+ const childBatches = new WeakMap();
2081
+
2082
+ const readChildrenAlone = (type, connection, parentId, args) => adapter.findChildren(
2083
+ type.model, type.gqltype, connection.storageFieldName, parentId, args, null,
2084
+ );
2085
+
2086
+ // Whether the adapter reads this type's children in batches, checked on every read as an adapter
2087
+ // method call. A predicate that throws keeps per-parent reads; an error other than a SimfinityError
2088
+ // is reported once per type.
2089
+ const batchesChildren = (type, connection) => {
2090
+ if (adapter.batchesChildren == null) return true;
2091
+ try {
2092
+ return adapter.batchesChildren(type.model, type.gqltype, connection.storageFieldName) !== false;
2093
+ } catch (error) {
2094
+ if (!(error instanceof SimfinityError)) {
2095
+ warnOnce(type.gqltype, 'batchesChildren', 'Configuration issue: adapter.batchesChildren failed for '
2096
+ + `${type.gqltype.name} (${error?.message ?? String(error)}); its collection fields read one parent at a time.`);
2097
+ }
2098
+ return false;
2099
+ }
2100
+ };
2101
+
2102
+ // Whether a batched child record may belong to the parent with this key: it is an object, and the
2103
+ // parent it stores under the connection storage field (or its GraphQL name), when it shows one, has
2104
+ // that key by string form or by castId. A stored list (a multikey connection) needs one such entry.
2105
+ const belongsToParent = (record, connection, key) => {
2106
+ if (record === null || typeof record !== 'object') return false;
2107
+ let stored = ownValue(record, connection.storageFieldName);
2108
+ if (stored === undefined && connection.graphqlFieldName) stored = ownValue(record, connection.graphqlFieldName);
2109
+ if (stored === undefined) return true;
2110
+ const matches = (value) => {
2111
+ const id = value?._id ?? value;
2112
+ if (id == null) return false;
2113
+ try {
2114
+ return String(id) === key || String(adapter.castId(id)) === key;
2115
+ } catch {
2116
+ return false;
2117
+ }
2118
+ };
2119
+ return Array.isArray(stored) ? stored.some(matches) : matches(stored);
2120
+ };
2121
+
2122
+ // The fields of one parent share its records, as references do, but each gets its own list.
2123
+ const settleChildren = (waiters, list) => waiters.forEach(({ resolve }, index) => {
2124
+ resolve(index === 0 ? list : [...list]);
2125
+ });
2126
+
2127
+ // After a failed read, each field reads alone, in its own promise job, with its own copy of the
2128
+ // arguments and its own parent ID, so an error fails only the fields it would fail alone.
2129
+ const readEachChildAlone = (type, group, waiters) => {
2130
+ for (const { parentId, resolve, reject } of waiters) {
2131
+ Promise.resolve()
2132
+ .then(() => readChildrenAlone(type, group.connection, parentId, clonePlainArgs(group.args)))
2133
+ .then(resolve, reject);
2134
+ }
2135
+ };
2136
+
2137
+ // Whether a batch result holds one list per parent, in order, of records of that parent. Every index is
2138
+ // checked, holes included, since Array.prototype.every skips empty slots.
2139
+ const listsMatchParents = (lists, parents, connection) => {
2140
+ if (!Array.isArray(lists) || lists.length !== parents.length) return false;
2141
+ for (let index = 0; index < lists.length; index++) {
2142
+ const records = Object.hasOwn(lists, index) ? lists[index] : undefined;
2143
+ if (!Array.isArray(records)) return false;
2144
+ for (let position = 0; position < records.length; position++) {
2145
+ if (!Object.hasOwn(records, position) || !belongsToParent(records[position], connection, parents[index].key)) return false;
2146
+ }
2147
+ }
2148
+ return true;
2149
+ };
2150
+
2151
+ const readChildren = async (type, group, parents, context) => {
2152
+ const { connection } = group;
2153
+ if (parents.length === 1) {
2154
+ // One distinct parent reads with findChildren, as before, shared by the fields that read it.
2155
+ const { waiters } = parents[0];
2156
+ let list;
2157
+ try {
2158
+ list = await readChildrenAlone(type, connection, waiters[0].parentId, clonePlainArgs(group.args));
2159
+ } catch (error) {
2160
+ if (waiters.length === 1) waiters[0].reject(error);
2161
+ else readEachChildAlone(type, group, waiters);
2162
+ return;
2163
+ }
2164
+ if (waiters.length > 1 && !Array.isArray(list)) readEachChildAlone(type, group, waiters);
2165
+ else settleChildren(waiters, list);
2166
+ return;
2167
+ }
2168
+ let lists;
2169
+ try {
2170
+ lists = await adapter.findChildrenBatch(type.model, type.gqltype, connection.storageFieldName,
2171
+ parents.map(({ waiters }) => waiters[0].parentId), clonePlainArgs(group.args), { context });
2172
+ } catch (error) {
2173
+ if (!(error instanceof SimfinityError)) {
2174
+ warnOnce(type.gqltype, 'findChildrenBatch', `adapter.findChildrenBatch failed for ${type.gqltype.name} `
2175
+ + `(${error?.message ?? String(error)}); the fields it read were read one parent at a time. Later failures `
2176
+ + 'for this type are not reported.');
2177
+ }
2178
+ parents.forEach(({ waiters }) => readEachChildAlone(type, group, waiters));
2179
+ return;
2180
+ }
2181
+ if (!listsMatchParents(lists, parents, connection)) {
2182
+ warnOnce(type.gqltype, 'findChildrenBatch', 'Configuration issue: adapter.findChildrenBatch did not return one '
2183
+ + `list of ${type.gqltype.name} records per parent ID, in parentIds order; the fields it read were read one `
2184
+ + 'parent at a time.');
2185
+ parents.forEach(({ waiters }) => readEachChildAlone(type, group, waiters));
2186
+ return;
2187
+ }
2188
+ parents.forEach(({ waiters }, index) => settleChildren(waiters, lists[index]));
2189
+ };
2190
+
2191
+ const flushChildren = (type, group, context) => {
2192
+ const parents = [...group.parents].map(([key, { waiters }]) => ({ key, waiters }));
2193
+ for (let start = 0; start < parents.length; start += CHILD_BATCH_SIZE) {
2194
+ const chunk = parents.slice(start, start + CHILD_BATCH_SIZE);
2195
+ // An unexpected error settles every field of the chunk that is still waiting.
2196
+ readChildren(type, group, chunk, context).catch((error) => {
2197
+ for (const { waiters } of chunk) waiters.forEach(({ reject }) => reject(error));
2198
+ });
2199
+ }
2200
+ };
2201
+
2202
+ // A generated collection read. It runs at once, with the arguments object it received, as before,
2203
+ // unless the adapter batches this type's children, the request has a context object, the parent ID
2204
+ // has a batch key, and the effective arguments are plain data with valid pagination and a key within
2205
+ // the budget. Then it joins the request's group for its related type, connection storage field and
2206
+ // arguments: the first read of a group copies the arguments, and later reads compute only the key.
2207
+ const loadChildren = (type, connection, parentId, args, context) => {
2208
+ if (typeof adapter.findChildrenBatch !== 'function' || typeof context !== 'object' || context === null
2209
+ || !batchesChildren(type, connection)) return readChildrenAlone(type, connection, parentId, args);
2210
+ const key = childParentKey(parentId);
2211
+ const argsKey = key === null ? null : childArgsKey(args);
2212
+ if (argsKey === null) return readChildrenAlone(type, connection, parentId, args);
2213
+ try {
2214
+ // Invalid pagination fails each field alone, as findChildren reports it.
2215
+ paginationStages(args.pagination, true);
2216
+ } catch {
2217
+ return readChildrenAlone(type, connection, parentId, args);
2218
+ }
2219
+ let byType = childBatches.get(context);
2220
+ if (!byType) {
2221
+ byType = new Map();
2222
+ childBatches.set(context, byType);
2223
+ }
2224
+ let groups = byType.get(type);
2225
+ if (!groups) {
2226
+ groups = new Map();
2227
+ byType.set(type, groups);
2228
+ }
2229
+ const groupKey = `${JSON.stringify(connection.storageFieldName)}${argsKey}`;
2230
+ let group = groups.get(groupKey);
2231
+ if (!group) {
2232
+ let snapshot;
2233
+ try {
2234
+ snapshot = clonePlainArgs(args);
2235
+ } catch {
2236
+ return readChildrenAlone(type, connection, parentId, args);
2237
+ }
2238
+ group = { connection, args: snapshot, parents: new Map() };
2239
+ groups.set(groupKey, group);
2240
+ const created = group;
2241
+ // As for references, flush once the promise jobs queued so far have run.
2242
+ Promise.resolve().then(() => process.nextTick(() => {
2243
+ groups.delete(groupKey);
2244
+ if (!groups.size && byType.get(type) === groups) byType.delete(type);
2245
+ flushChildren(type, created, context);
2246
+ }));
2247
+ }
2248
+ return new Promise((resolve, reject) => {
2249
+ if (!group.parents.has(key)) group.parents.set(key, { waiters: [] });
2250
+ group.parents.get(key).waiters.push({ parentId, resolve, reject });
2251
+ });
2252
+ };
2253
+
1919
2254
  // Why a type's generated create or update input has no fields, for the warnings of buildMutation. An
1920
2255
  // update input also needs a writable id.
1921
2256
  const withoutWritableFields = (type, { noWritableId = false } = {}) => {
@@ -2122,6 +2457,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2122
2457
  await executeMiddleware(params);
2123
2458
  await executeScope(params);
2124
2459
  await applyJoinedScopes(joinedScopes, params);
2460
+ assertStoredQueryPaths(type.gqltype, params.args, 'find');
2125
2461
  const queryArgs = params.args;
2126
2462
  const wantsCount = !!(queryArgs.pagination && queryArgs.pagination.count)
2127
2463
  && canReportCount(parent, info, context);
@@ -2142,7 +2478,8 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2142
2478
  };
2143
2479
 
2144
2480
  const aggregateArgsObject = { ...argsObject };
2145
- if (Object.hasOwn(type.gqltype.getFields(), 'aggregation')) {
2481
+ const fields = type.gqltype.getFields();
2482
+ if (Object.hasOwn(fields, 'aggregation') && !isVirtualField(fields.aggregation)) {
2146
2483
  warnOnce(type.gqltype, 'aggregation', `Configuration issue: ${type.gqltype.name}.aggregation has the name `
2147
2484
  + `of the aggregation argument of ${type.listEntitiesEndpointName}_aggregate, so that endpoint cannot `
2148
2485
  + 'filter it with a top-level argument; filter it in AND/OR group conditions or rename the field.');
@@ -2166,6 +2503,7 @@ const buildRootQuery = (name, includedTypes, aggregationResultType = QLTypeAggre
2166
2503
  await executeMiddleware(params);
2167
2504
  await executeScope(params);
2168
2505
  await applyJoinedScopes(joinedScopes, params);
2506
+ assertStoredQueryPaths(type.gqltype, params.args, 'aggregate');
2169
2507
  return adapter.aggregate(type.model, type.gqltype, params.args, null);
2170
2508
  },
2171
2509
  };
@@ -2270,7 +2608,7 @@ const holdsNoData = (stored, type, ancestors = []) => !ancestors.includes(stored
2270
2608
  // which buildInputType leaves out; nor do referenced collections or members with an application
2271
2609
  // resolver, which may compute a value.
2272
2610
  const countsAsRequired = (type, memberName, member) => member.type instanceof GraphQLNonNull
2273
- && !isReferencedCollection(member) && memberName !== 'id' && !member.extensions?.readOnly
2611
+ && !isReferencedCollection(member) && memberName !== 'id' && !isReadOnlyField(member)
2274
2612
  && !isAbstractOutputType(unwrapListAndNonNull(member.type)) && !hasApplicationResolver(type, memberName);
2275
2613
  const hasRequiredMember = (type) => Object.entries(type.getFields())
2276
2614
  .some(([memberName, member]) => countsAsRequired(type, memberName, member));
@@ -2365,6 +2703,23 @@ const warnOnce = (gqltype, key, message) => {
2365
2703
  console.warn(message);
2366
2704
  };
2367
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
+
2368
2723
  // A writable referenced collection without a child connectionField would store unlinked children
2369
2724
  // through generated nested writes and read none back, so it is rejected before anything is built.
2370
2725
  // One with its own resolver only warns, and its nested writes are rejected per request; a readOnly
@@ -2385,6 +2740,50 @@ const assertCollectionConnections = () => {
2385
2740
  }
2386
2741
  };
2387
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
+
2388
2787
  const getRegistrations = () => Object.values(typesDict.types);
2389
2788
 
2390
2789
  // Application types that a schema with these allowlists contains, whatever else it reaches: the
@@ -2407,6 +2806,9 @@ const createSchema = (includedQueryTypes, includedMutationTypes, includedCustomM
2407
2806
  const schemaTypes = collectSchemaOutputTypes();
2408
2807
  schemaTypes.forEach(assertNotBoundElsewhere);
2409
2808
  schemaTypes.forEach(assertFieldsNotBoundElsewhere);
2809
+ if (assertVirtualFields(schemaTypes)) virtualFieldsDeclared = true;
2810
+ warnUndeclaredVirtualFields();
2811
+ assertPlainConnectionFields();
2410
2812
  assertCollectionConnections();
2411
2813
  // An application type with the name of a field metadata type that an earlier schema fixed; one that
2412
2814
  // only generated types or the fields of an input object reach is found when the schema is
@@ -2555,14 +2957,8 @@ const autoGenerateResolvers = (gqltype) => {
2555
2957
  await executeMiddleware(params);
2556
2958
  await executeScope(params);
2557
2959
  await applyJoinedScopes(joinedScopes, params);
2558
- return adapter.findChildren(
2559
- relatedTypeInfo.model,
2560
- relatedTypeInfo.gqltype,
2561
- connection.storageFieldName,
2562
- parent._id ?? parent.id,
2563
- params.args,
2564
- null,
2565
- );
2960
+ assertStoredQueryPaths(relatedType, params.args, 'find');
2961
+ return loadChildren(relatedTypeInfo, connection, parent._id ?? parent.id, params.args, context);
2566
2962
  }, { simfinityQuery: { typeName: relatedType.name, operation: 'find' } });
2567
2963
  runtimeBoundTypes.set(gqltype, runtimeIdentity);
2568
2964
  } else if (fieldEntry.type instanceof GraphQLObjectType
@@ -2662,11 +3058,12 @@ const LIST_QUERY_ARGS = ['pagination', 'sort', 'AND', 'OR'];
2662
3058
 
2663
3059
  // `omit` is a collection's back-reference field, which its parent supplies, so it gets no filter.
2664
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.
2665
3062
  const createArgsForQuery = (gqltype, omit = null) => {
2666
3063
  const argsObject = {};
2667
3064
  const argTypes = gqltype.getFields();
2668
3065
  for (const name of LIST_QUERY_ARGS) {
2669
- if (name !== omit && Object.hasOwn(argTypes, name)) {
3066
+ if (name !== omit && Object.hasOwn(argTypes, name) && !isVirtualField(argTypes[name])) {
2670
3067
  warnOnce(gqltype, name, `Configuration issue: ${gqltype.name}.${name} has the name of a generated list `
2671
3068
  + 'query argument, so list queries and collection fields cannot filter it with a top-level argument; '
2672
3069
  + 'filter it in AND/OR group conditions or rename the field.');
@@ -2674,7 +3071,7 @@ const createArgsForQuery = (gqltype, omit = null) => {
2674
3071
  }
2675
3072
 
2676
3073
  for (const [fieldEntryName, fieldEntry] of Object.entries(argTypes)) {
2677
- if (fieldEntryName === omit) continue;
3074
+ if (fieldEntryName === omit || isVirtualField(fieldEntry)) continue;
2678
3075
  let filterType = null;
2679
3076
 
2680
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
+ };
@@ -8,6 +8,7 @@ export type {
8
8
  AuthPluginOptions,
9
9
  AuthRule,
10
10
  AuthRuleFunction,
11
+ AuthSchemaPlugin,
11
12
  EnvelopSchemaPlugin,
12
13
  PermissionSchema,
13
14
  PolicyExpression,
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
@@ -340,6 +410,41 @@ export interface DatabaseAdapter<Model = any, Session = any> {
340
410
  count(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): Promise<number> | number;
341
411
  aggregate(model: Model, type: GraphQLObjectType, args: any, session?: Session | null): any;
342
412
  findChildren(model: Model, type: GraphQLObjectType, connectionField: string, parentId: any, args: any, session?: Session | null): any;
413
+ /**
414
+ * Optional batch form of `findChildren` for generated collection fields. The runtime calls it as
415
+ * an adapter method (`this` is the adapter) only when `batchesChildren` does not return false, and
416
+ * checks both members on every read. Within one request (a GraphQL context object), it groups the
417
+ * collection reads of one related type and connection storage field whose effective arguments
418
+ * (after middleware, the scope and joined scopes) hold the same plain data and are requested
419
+ * before the promise jobs queued so far have run, and calls this method with 2 to 100 distinct
420
+ * parent IDs: each one the raw ID the first of its fields read, never null, and one that `castId`
421
+ * accepts without changing its string form. A group with one distinct parent calls `findChildren`
422
+ * instead. `args` is a copy the method may change; it holds only plain data and dates, and its
423
+ * pagination is valid. `options.context` is the request context; the runtime passes no session.
424
+ *
425
+ * Return, or resolve to, one array per parent ID, in `parentIds` order, each holding exactly the
426
+ * records, order and page that `findChildren(model, type, connectionField, parentIds[i], args, null)`
427
+ * returns for that parent; the result must not depend on `options.context`. Records should show
428
+ * their parent under `connectionField` (or the connection field's GraphQL name), as `findChildren`
429
+ * records do, or not show one. Fields that read the same parent share its records, each with its
430
+ * own array. After a rejection or throw, or a result of another shape (not an array, another
431
+ * length, an entry that is not an array, or a record that shows another parent), each field reads
432
+ * alone with `findChildren`; a failure other than a `SimfinityError` and a result of another shape
433
+ * are reported once per type with `console.warn`. To keep per-parent reads for a model or field,
434
+ * return false from `batchesChildren`.
435
+ */
436
+ findChildrenBatch?: ((model: Model, type: GraphQLObjectType, connectionField: string, parentIds: any[], args: any, options?: { context?: any }) => any[][] | PromiseLike<any[][]>) | undefined;
437
+ /**
438
+ * Optional, synchronous. Return false to read the children of `model` through `connectionField`
439
+ * with one `findChildren` call per field, as an adapter without `findChildrenBatch` does: at once,
440
+ * with the arguments object the field built. The runtime calls it as an adapter method on every
441
+ * read that could join a batch. Return false whenever `findChildrenBatch` would not reproduce
442
+ * `this.findChildren`, such as when `this.findChildren` is not the adapter's own function (a copy
443
+ * or an `Object.assign` override that changes how children are read), or for a model whose reads
444
+ * have hooks or settings that a batched read would bypass. When it is absent, the runtime batches
445
+ * every read it can.
446
+ */
447
+ batchesChildren?: ((model: Model, type: GraphQLObjectType, connectionField: string) => boolean) | undefined;
343
448
  }
344
449
  export function createRuntime<Model = any, Session = any>(adapter: DatabaseAdapter<Model, Session>): Runtime<Model, Session>;
345
450
 
@@ -363,15 +468,34 @@ export function configureQueryLimits(options?: { maxPageSize?: number }): void;
363
468
  export function configureMutationLimits(options?: MutationLimitsOptions): void;
364
469
  export function paginationStages(pagination: { page: number; size: number } | null | undefined, withDefault: boolean): Array<{ $skip: number } | { $limit: number }>;
365
470
 
366
- /** Envelop-style schema plugin used by the shared authorization helpers. */
471
+ /**
472
+ * Envelop-style schema plugin, as MCP `schemaPlugins` accepts it; `createAuthPlugin` returns the
473
+ * narrower {@link AuthSchemaPlugin}. `@simtlix/simfinity-mcp` re-exports this declaration, so
474
+ * `export *` of both packages exports one `EnvelopSchemaPlugin`.
475
+ */
367
476
  export interface EnvelopSchemaPlugin {
477
+ /**
478
+ * Wraps resolvers in place. MCP `schemaPlugins` awaits a returned promise; Envelop and Yoga do
479
+ * not, so plugins shared with them should install synchronously.
480
+ */
368
481
  onSchemaChange?: (payload: {
369
482
  schema: GraphQLSchema;
370
483
  replaceSchema: (schema: GraphQLSchema) => void;
371
- }) => void;
484
+ }) => void | Promise<void>;
372
485
  [key: string]: unknown;
373
486
  }
374
487
 
488
+ /**
489
+ * What `createAuthPlugin` returns: an {@link EnvelopSchemaPlugin} whose hook wraps the resolvers
490
+ * synchronously and returns nothing.
491
+ */
492
+ export interface AuthSchemaPlugin extends EnvelopSchemaPlugin {
493
+ onSchemaChange?: (payload: {
494
+ schema: GraphQLSchema;
495
+ replaceSchema: (schema: GraphQLSchema) => void;
496
+ }) => void;
497
+ }
498
+
375
499
  /** A single field validator (throws SimfinityError VALIDATION_ERROR on failure). */
376
500
  export interface FieldValidator {
377
501
  validate(typeName: string, fieldName: string, value: unknown, session?: unknown): Promise<void>;
@@ -505,7 +629,7 @@ export const auth: {
505
629
  * `onSchemaChange` throws TypeError, before wrapping any field, when a schema type was created
506
630
  * by another copy of the graphql module.
507
631
  */
508
- createAuthPlugin(permissions: PermissionSchema, options?: AuthPluginOptions): EnvelopSchemaPlugin;
632
+ createAuthPlugin(permissions: PermissionSchema, options?: AuthPluginOptions): AuthSchemaPlugin;
509
633
  /** @deprecated Use createAuthPlugin instead. graphql-middleware compatible middleware. */
510
634
  createAuthMiddleware(
511
635
  permissions: PermissionSchema,
@@ -575,7 +699,7 @@ export const auth: {
575
699
 
576
700
  /** GraphQL server plugins: auth plugin factory plus count-extension plugins. */
577
701
  export const plugins: {
578
- createAuthPlugin(permissions: PermissionSchema, options?: AuthPluginOptions): EnvelopSchemaPlugin;
702
+ createAuthPlugin(permissions: PermissionSchema, options?: AuthPluginOptions): AuthSchemaPlugin;
579
703
  /** Apollo Server plugin that copies `contextValue.count` into `extensions.count`. */
580
704
  apolloCountPlugin(): {
581
705
  requestDidStart(): Promise<{
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { plugins } from './index.js';
6
6
 
7
- export type { AuthPluginOptions, EnvelopSchemaPlugin, PermissionSchema } from './index.js';
7
+ export type { AuthPluginOptions, AuthSchemaPlugin, EnvelopSchemaPlugin, PermissionSchema } from './index.js';
8
8
  // The /auth declaration itself, as the runtime re-exports that binding, so the two subpaths can be
9
9
  // re-exported together.
10
10
  export { createAuthPlugin } from './auth/index.js';