@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 +6 -4
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/introspection.js +10 -1
- package/src/metadata.js +17 -2
- package/src/relation-storage.js +53 -3
- package/src/runtime.js +109 -11
- package/src/virtual-fields.js +135 -0
- package/types/index.d.ts +73 -3
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
|
|
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
|
|
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
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 {
|
package/src/introspection.js
CHANGED
|
@@ -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:
|
|
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
|
-
/**
|
|
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
|
|
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')) {
|
package/src/relation-storage.js
CHANGED
|
@@ -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
|
|
4
|
-
// identity. They are shared by the core runtime and
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
|
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
|