neuron-explorer
Steering-first neuron explorer for Qwen3.5-4B: push every MLP neuron, keep every rollout, browse the results
Build a GraphQL API from your types. Simfinity.js generates MongoDB or PostgreSQL storage, queries, mutations, relationships, and optional MCP tools.
Developer toolsJavaScriptGitHub @simtlixOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Generate GraphQL APIs, relationships and database storage from GraphQL object types. Node.js, MongoDB/Mongoose or PostgreSQL, with validation, authorization hooks and optional MCP tools.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
A Node.js framework that turns GraphQL object types into generated queries, mutations, relationships, and database storage. Use the established MongoDB/Mongoose facade or the PostgreSQL 15+ facade with real tables and foreign keys.
Read the documentation website: start with the quick start, explore the guides, or consult the API reference. The website source is in docs/.Read the full README ↗A Node.js framework that turns GraphQL object types into generated queries, mutations, relationships, and database storage. Use the established MongoDB/Mongoose facade or the PostgreSQL 15+ facade with real tables and foreign keys.
Read the documentation website: start with the quick start, explore the guides, or consult the API reference. The website source is in docs/.
For a complete application, run the Barber examples: independent MongoDB and PostgreSQL backends with one shared Next.js frontend, synthetic demo data, Docker setup, and a dedicated CI workflow. Both backends consume released Simfinity 3.8.0 packages from npm. Their shared HTTP matrix verifies filters, aggregates, scopes and nested mutations; see the API test commands and reference-integrity boundary.
Run the documentation website locally with Node.js 22.12.0+:
npm run docs:install
npm run docs:dev
For builds and hosting, see the website maintainer guide. The website documents the current source; some older examples later in this README retain historical conventions.
Documentation for both databases: The public website now covers MongoDB and PostgreSQL, including shared APIs, relationships, generated FKs, scopes, and MCP. Both adapters are available on npm and released together. Follow the quick starts for installation, or download the runnable starters and verified release archives.
id index is unused, since the record key is _id; onModelCreated: (model) => model.schema.removeIndex({ id: 1 }) removes it from a connect() type. See the index referencenpm install mongoose@^8.24.2 graphql@^16.11.0 @simtlix/[email protected]
Prerequisites: Simfinity.js requires mongoose and graphql as peer dependencies. Keep them within the ranges above so your application and Simfinity share a single Mongoose and GraphQL instance; npm reports an out-of-range version as a peer conflict. The MCP transports need the optional peer @modelcontextprotocol/sdk@^1.31.0, which is not installed automatically, and graphql-middleware is not a Simfinity dependency.
Simfinity releases @simtlix/simfinity-core, @simtlix/simfinity-sql, @simtlix/simfinity-mcp, @simtlix/simfinity-postgres, and the MongoDB facade in lockstep. Install the selected adapter from npm; shared dependencies resolve automatically. PostgreSQL runs the shared GraphQL query/mutation engine, including scopes, controllers, validators, state transitions and nested writes. It generates and validates tables, indexes, and real foreign keys, including inverse relations, explicit many-to-many linking entities, and references inside embedded objects. PostgreSQL installation does not pull Mongoose, MongoDB, or MCP dependencies.
Version 3.3.0 separates the driver-free relational runtime into @simtlix/simfinity-sql. PostgreSQL supplies the first SQL plugin; it owns physical SQL, types, schema initialization and pg. Existing createPostgres and namespace imports remain compatible, with unchanged generated PostgreSQL storage and FKs. Use the explicit composition API when you want to select a plugin:
import { createSQL } from '@simtlix/simfinity-sql';
import { postgresPlugin } from '@simtlix/simfinity-postgres';
const simfinity = createSQL({ plugin: postgresPlugin({ pool, schema: 'app' }) });
Install both packages directly when importing both. Only PostgreSQL is supported initially; see the SQL plugin guide for the contract, capabilities, dependency boundaries and extension requirements.
All publishable libraries live under packages/:
| Directory | npm package |
|---|---|
packages/core | @simtlix/simfinity-core |
packages/sql | @simtlix/simfinity-sql |
packages/mongodb | @simtlix/simfinity-js |
packages/postgres | @simtlix/simfinity-postgres |
packages/mcp | @simtlix/simfinity-mcp |
packages/descriptor | @simtlix/simfinity-descriptor |
The repository root is a private npm workspace for shared tests, documentation and release tooling. MongoDB retains its existing package name, public API and deep imports such as @simtlix/simfinity-js/src/auth/rules.js. Its legacy src/ modules that re-export public API (src/index.js, src/auth/index.js, src/auth/errors.js, src/auth/expressions.js, src/auth/rules.js, src/plugins.js, src/scalars.js, src/validators.js, src/mcp.js, src/const/*.js, src/errors/*.js, and their extensionless aliases) ship TypeScript declarations with the types of the package root and the matching @simtlix/simfinity-core subpaths; src/mongo/* is internal and undeclared. New code should still import auth, plugins, scalars and validators from the package root. The typed @simtlix/simfinity-core subpaths, such as @simtlix/simfinity-core/auth, need @simtlix/simfinity-core as a direct dependency pinned to the facade's exact version. Run development commands from the root; pack MongoDB with npm pack --workspace @simtlix/simfinity-js or use the release scripts to pack all six libraries. Pushing a release tag (vX.Y.Z) or publishing a GitHub release automatically publishes all six packages with GitHub Actions, attaches verified archives to the release, and deploys the stable documentation. Version changes and merges to master do not publish; the tag must match the aligned package versions and point to source already merged into master. See the release and OIDC setup guide.
The shared development and publication contract applies to contributors and coding agents: when a task includes publication, completion requires the GitHub tag and published release, verified packages in both registries, and the applicable documentation deployment. This rule is tracked in AGENTS.md and the always-applied Cursor workflow rule.
Enum filters resolve member names first, then declared internal values by strict equality, on both backends. For example, with ONE: { value: 'TWO' } and TWO: { value: 'two' }, filter "TWO" selects member TWO. Numeric internal values require numbers, not numeric strings. This applies to scalar lists, embedded/reference leaves and state filters across EQ, NE, LT, LTE, GT, GTE, BTW, IN and NIN. LIKE accepts string fields only. PostgreSQL writes and state guards continue to use internal enum values. Generated MongoDB models store numeric and boolean enum values as numbers and booleans, so MongoDB range filters, sorts and aggregate groupId values follow that type while PostgreSQL uses their text form ("10" before "2"); convert documents written by earlier versions as described in the schema guide.
The shared runtime preserves the v3.1 contract: generated relationships run target middleware/scopes with protected identity and parent filters; nested mutations enforce child middleware and persisted ownership; standalone saveObject() wraps the complete workflow in a transaction. Accepted empty strings are preserved. Both facades expose configureQueryLimits() for bounded pagination and configureMutationLimits() for bounded nested writes.
Choose the backend at application setup. The existing package continues to use MongoDB; PostgreSQL uses createPostgres({ pool, schema }), the same connect(null, Type, ...)/createSchema() signatures, and an awaited initializeDatabase() before serving requests. See the canonical PostgreSQL quick start, detailed storage reference, and compatibility contract before adopting this version.
Both database facades expose the same auth, validators, scalars, and plugins helper objects. PostgreSQL keeps MCP optional; install the database-independent integration and its transport SDK only when needed:
npm install @simtlix/[email protected] @modelcontextprotocol/sdk@^1.31.0
Import generateMCPTools, createMCPServer, or the transport helpers from @simtlix/simfinity-mcp and pass the schema returned by createPostgres().createSchema().
Compatibility tests run the same GraphQL schemas and query corpus against both databases. IDs use UUIDs on PostgreSQL, native model/session APIs differ, and some mappings remain explicitly unsupported, including whole embedded-object uniqueness and whole-object sorting/grouping. Nested embedded scalar-list filters/sorts, ragged array group keys and array aggregate facts now match the supported Mongo query contract. Native embedded writes retain explicit nulls, apply descendant list defaults and minimize empty inline objects; historical Date parameters use UTC. PostgreSQL enforces scalar/list multikey uniqueness within embedded trees through typed owner-key tables, validates JSONB embedded shapes, and checks owned marker/row consistency with deferred database triggers. MongoDB fixes also cover list nullability wrappers, scalar-ID inverse relations, inverse aggregation paths, embedded array replacement after clearing, and isolation of mutation input across transaction retries. Existing API signatures remain unchanged.
The complete quick start includes MongoDB setup, installation, and working create, read, update, and delete operations. Simfinity uses ES modules and named exports.
npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.24.2 graphql-yoga@^5
Set "type": "module" in your application's package.json, then create server.js:
import { createServer } from 'node:http';
import { GraphQLID, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { createYoga } from 'graphql-yoga';
import mongoose from 'mongoose';
import * as simfinity from '@simtlix/simfinity-js';
const SerieType = new GraphQLObjectType({
name: 'Serie',
fields: {
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
},
});
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/series?replicaSet=rs0&directConnection=true');
simfinity.connect(null, SerieType, 'serie', 'series');
const schema = simfinity.createSchema();
const yoga = createYoga({ schema });
createServer(yoga).listen(4000, '127.0.0.1', () => {
console.log('GraphQL ready at http://127.0.0.1:4000/graphql');
});
Generated mutations require a MongoDB replica set or sharded cluster. Follow the local MongoDB setup, then run node server.js and open http://127.0.0.1:4000/graphql:
mutation {
addserie(input: { name: "The Expanse" }) {
id
name
}
}
query {
series {
id
name
}
}
For a full working application, see the Barber examples, with MongoDB and PostgreSQL backends, a shared frontend, relationships, state machines, controllers, and authorization.
The simfinity.connect() method links your GraphQL types to Simfinity's automatic schema generation:
simfinity.connect(
mongooseModel, // Optional: Custom Mongoose model (null for auto-generation)
graphQLType, // Required: Your GraphQLObjectType
singularEndpointName, // Required: Singular name for mutations (e.g., 'book')
pluralEndpointName, // Required: Plural name for queries (e.g., 'books')
controller, // Optional: Controller with lifecycle hooks
onModelCreated, // Optional: Callback when Mongoose model is created
stateMachine // Optional: State machine configuration
);
Generate your complete GraphQL schema with optional type filtering:
const schema = simfinity.createSchema(
includedQueryTypes, // Optional: Array of types to include in queries
includedMutationTypes, // Optional: Array of types to include in mutations
includedCustomMutations, // Optional: Array of custom mutation names to include
{ includedCustomQueries, transaction } // Optional: Options object; custom query names to include and the transaction mutation
);
buildClientDescriptor(schema) now provides immutable JSON metadata for the exact schema returned by createSchema(), including actual operation names, inputs, relations, virtual fields and state transitions. Import it from the optional @simtlix/simfinity-descriptor package, aligned with the facade version. Available since 3.15.0. Mount createClientDescriptorHandler(schema, { authorize: 'public' }) explicitly to serve it; both functions are named exports of the descriptor package. For example:
import { buildClientDescriptor, createClientDescriptorHandler } from '@simtlix/simfinity-descriptor';
const descriptor = buildClientDescriptor(schema);
console.log(descriptor.operations.query);
app.all('/graphql-metadata', simfinity.createClientDescriptorHandler(schema, { authorize: 'public' }));
The 3.x introspection default stays enabled. A new public @simtlix/simfinity-core/introspection subpath exports configureIntrospection({ fieldExtensions: false }), which requires core as a direct dependency pinned to the facade's exact version and must execute before importing the runtime or facades and never removes installed metadata. Current legacy metadata clients need migration before using the opt-out. See the client descriptor reference for bootstrap ordering, protected HTTP access, format and composition limits.
Importing Simfinity initializes its global __Field.extensions introspection field safely whether GraphQL's fields have already been materialized or a schema already exists. Repeated module evaluation with the same GraphQL peer reuses the existing extension field and metadata types. Application field metadata is preserved.
The extension remains shared by every schema using that GraphQL peer; Simfinity's type and middleware registries belong to each runtime instance. A GraphQLObjectType belongs to the first runtime whose schema generates its relation resolvers, or reaches it while one of its non-embedded relation fields has no resolver, such as an unregistered custom mutation result. Registering it in another runtime, reaching it from another runtime's schema, or copying its relation fields with toConfig() after that runtime generated them, throws TYPE_BOUND_TO_OTHER_RUNTIME. Give each runtime its own type objects, for example from a factory function. Types without non-embedded relation fields, and types whose relations all have your own resolvers, can be shared; see types belong to one runtime. Schemas constructed after import include FieldExtensionsType and RelationType in their type maps, or their fallback names SimfinityFieldExtensionsType and SimfinityRelationType once an application type with one of those names was in the process's first Simfinity schema; see what gets generated. Earlier schemas keep their original type maps: ordinary operations and direct metadata selections work, but named fragments on the new metadata types require a schema constructed after import.
Schema-cloning tools remain unsupported. In a process that has imported Simfinity, buildClientSchema() on a post-import schema's introspection result can also fail with duplicate metadata type names. Use Envelop plugins and in-place resolver wrapping; see the introspection metadata reference.
Opt into reference checks when creating the MongoDB adapter. The default remains 'off', preserving existing applications:
import { createMongoAdapter, createRuntime } from '@simtlix/simfinity-js';
const adapter = createMongoAdapter({ referentialIntegrity: 'transactional' });
const simfinity = createRuntime(adapter);
await mongoose.connect(process.env.MONGODB_URI);
simfinity.connect(null, SerieType, 'serie', 'series');
const schema = simfinity.createSchema();
await adapter.initialize();
Register the complete type graph and await initialization before serving HTTP or MCP. Initialization audits existing references and verifies a transaction-capable MongoDB deployment. Protected writes reject missing targets and restrict deletion of referenced documents, including references within embedded lists, inferred/private inverse keys and explicit many-to-many links. Target-document writes coordinate concurrent creation/deletion. Failures return REFERENCE_CONSTRAINT_VIOLATION (409) and roll back the mutation.
The mode is fixed at startup. All writers must share the same graph/mode; raw Mongoose/driver writes bypass this protection. PostgreSQL retains native database FKs. See MongoDB reference integrity for snapshot/majority sessions, reserved lock metadata, contention and existing-data migration.
// Prevent automatic MongoDB collection creation (useful for testing)
simfinity.preventCreatingCollection(true);
// Cap the nested added/updated/deleted entries of one generated mutation (process-wide; unlimited by default)
simfinity.configureMutationLimits({ maxNestedOperations: 100 });
A mutation over the limit fails with NESTED_OPERATIONS_EXCEEDED (400) before its transaction starts. Call configureMutationLimits() without arguments to remove the limit. Unknown options, such as a misspelled maxNestedOperation, throw INVALID_MUTATION_LIMITS (400) and keep the current limit.
Since 3.16.0, updateObject() and transitionObject() apply maxNestedOperations to their input like the generated mutations, and configureMutationLimits({ maxNestedOperations, maxTransactionOperations }) also caps the operations of one transaction mutation (a positive safe integer; 100 by default). Each call replaces both values, so an omitted option takes its default again, and the unknown-option message reads Unknown mutation limit option <key>; use maxNestedOperations or maxTransactionOperations. Inside a transaction mutation the nested limit is one joint budget for every nested entry that runs on an attempt's session, including those that hooks and helper calls add.
Simfinity automatically generates queries for each connected type:
// For a BookType, you get:
// - book(id: ID): Book - Get single book by ID
// - books(...filters): [Book] - Get filtered list of books
Simfinity automatically generates mutations for each connected type:
// For a BookType, you get:
// - addBook(input: BookInput): Book
// - updateBook(input: BookInputForUpdate): Book
// - deleteBook(id: ID): Book
A type without writable fields gets no add mutation, and, when its id is missing or readOnly, no update mutation or state machine actions either; Simfinity logs a Configuration issue warning. See what gets generated.
Creation inputs retain required scalar, enum, embedded-object, and list fields. Update inputs remove the outer non-null wrapper so fields can be omitted; the entity id remains required for GraphQLID and GraphQLID!. Other ID fields remain optional on update. List item nullability is preserved:
| Output field | Create input | Update input |
|---|---|---|
[String] | [String] | [String] |
[String]! | [String]! | [String] |
[String!] | [String!] | [String!] |
[String!]! | [String!]! | [String!] |
The same wrapper handling applies to enum lists, supported custom scalar lists, and embedded lists (using their nested input types). Referenced collections keep the added / updated / deleted input object: a required collection requires that object on create, and non-null object items make added and updated items non-null. Nullable collection-operation items are ignored. Use deleted to delete children. Collections can form trees and cycles across types, and nested inputs follow them at any depth; deleted is [ID] for unprotected children whatever the item nullability, and null IDs are ignored. A protected child instead uses [RecordVersionRef] with { id, version } entries, and its updated entries require version, even through an unprotected parent. See generated collection input names.
Empty strings, false, 0, and empty arrays are persisted after validators accept them. Omitting an update field leaves its value unchanged. Explicit null clears a nullable scalar, embedded field, or single-object reference; references clear the actual stored connectionField, or the GraphQL field name when no override is configured. Multiple nullable fields can be cleared in one update. An explicit null for an originally non-null field leaves its stored value unchanged; use an update validator to reject that input if needed. Inside an embedded object patch, an explicit null clears a nullable member the same way, but the member is stored as null (a list member as []) instead of being removed; see update selected fields.
Every relationship or embedded terms entry is combined with AND, including repeated paths such as age GTE 18 and age LTE 30. These conditions remain ANDed with top-level logical groups and scope filters. Filter and list-sort paths must resolve to declared scalar or enum fields, except that a filter path of IS_EMPTY or IS_NOT_EMPTY may also end at an embedded object or an embedded list, and, on an adapter that supports it, at a reference or a collection, or be left out to test the field itself; see empty values. Client filter, sort and aggregation paths cannot name a field with extensions.queryable: false or an application-defined resolve unless it sets extensions.queryable: true; such paths fail with FORBIDDEN_FILTER_PATH (403). This covers masking resolvers, relationship fields with a manual resolver, and a custom id resolver. When upgrading, remove manual relationship resolvers that only load the related record, because Simfinity generates them, or set queryable: true on fields whose resolver returns the stored value. Paths through scoped relationships apply the target's scope, as described under Query Scope, and the authorization plugin checks field rules for every path segment. Root and relationship id paths refer to the stored _id. Inside an embedded object, id is the declared id member, except for entity types embedded as lists and supplied subdocument schemas without an id path, which use the subdocument _id; see IDs in paths. Comparisons use the connected Mongoose model's schema, so supplied string or numeric ID models retain their identifier representation.
On both bundled adapters, collection filter paths are correlated ANY frames and return each record once in lists, counts and pages. One group shares one matching child per collection prefix; separate groups may match different children. Explicit ANY/ALL/NONE groups and relation-filter quantifier use element-relative paths; without elements they hold false/true/true. Client elements use the target's find scope, while server-added conditions read stored data. A sort through a collection gives INVALID_FILTER_PATH before scopes, including count mode. Aggregate prefixes used by groupId or facts retain joins; filter-only prefixes become frames. See collection frames and the 3.14.0 upgrade notes.
Filter values must use the field's JSON scalar type. IN and NIN require flat lists, BTW requires exactly two non-null bounds, and LIKE and the other text operators require a string value on a String field or list. IIN requires a list of strings without U+0000 on a String field or list, and IIN [] matches nothing. CONTAINS_ALL requires a non-empty flat list of non-null scalars without objects, so [] is rejected, and applies to a scalar list or a field read through an embedded list, where every value must be among the values of all items; see filters on lists. Literal objects, nested lists, null list elements, invalid operators, unknown paths, and malformed groups are rejected with a structured 400 error instead of being ignored. Explicit null remains supported by EQ and NE. List items may be operation variables, as in value: [$from, $to]. Declare them as QLValue (or QLValue!): GraphQL rejects other declared types, such as Int or String, in that position. A list item whose variable the request leaves unset reads as null, as GraphQL reads list arguments, so it is rejected like an explicit null element. Enum literals such as value: ACTIVE or value: [ACTIVE, INACTIVE] are read as the enum names, like "ACTIVE". Enum names or declared internal enum values are converted to the stored representation; state-machine state filters preserve stored state names. Date filters convert valid date values without mutating the query input. Validated string scalars support partial text searches, and range filters use their base scalar type rather than the field's create/update validation constraints.
Query with powerful filtering options:
query {
books(
title: { operator: LIKE, value: "Galaxy" }
author: { operator: EQ, value: "Douglas Adams" }
pagination: { page: 1, size: 10, count: true }
sort: { terms: [{ field: "title", order: ASC }] }
) {
id
title
author
}
}
EQ - EqualNE - Not equalGT - Greater thanLT - Less thanGTE - Greater than or equalLTE - Less than or equalIN - In arrayNIN - Not in arrayIIN - In array, ignoring case (String fields and lists)CONTAINS_ALL - A list holds every value of the arrayBTW - Between two valuesLIKE - Literal substring, respecting caseILIKE - Literal substring, ignoring caseSTARTS_WITH, ISTARTS_WITH - Literal prefix, respecting or ignoring caseENDS_WITH, IENDS_WITH - Literal suffix, respecting or ignoring caseIEQ - Equal, ignoring caseIS_EMPTY, IS_NOT_EMPTY - The field holds no value, or holds one; they take no valueThe text operators match their value literally, so %, _ and regular-expression characters match themselves, and "" matches every non-null string except with IEQ. On a String list, some element must match. Both backends fold case with the Unicode simple case foldings, except for İ and letters newer than one side's Unicode tables, and on PostgreSQL the case-insensitive operators need a UTF8 database on a server built with ICU; see text operators for the rules, the differences and the PostgreSQL indexes that serve them, which caseFoldExpression() writes. IIN folds case as the other case-insensitive operators do: it matches where IEQ matches one of its values. A custom database adapter or SQL plugin receives them, and IIN and CONTAINS_ALL, only after it declares them in queryFeatures.
IS_EMPTY holds for a String that is missing, null or "", any other scalar or enum that is missing or null, a list that is missing, null or [] ([null] is not empty), and an embedded object that is missing, null or whose stored members are all empty; IS_NOT_EMPTY is its exact complement. Omit value or pass null. On an embedded object or list, write them as a term or group condition without path, such as address: { terms: [{ operator: IS_EMPTY }] } or { field: "address", operator: IS_EMPTY }. On a list, EQ, NE, IN and NIN compare the elements, so EQ null also matches [null] and never []. They also test a reference or a collection, such as season: { terms: [{ operator: IS_EMPTY }] }, and fields reached through references, such as season.name: for the client's conditions, a record that the target type's find scope hides reads as a missing one, while conditions that middleware and scopes add read stored data. A custom database adapter or SQL plugin answers these only when it declares referenceEmptiness in queryFeatures, and otherwise rejects them with UNSUPPORTED_QUERY; see references and collections. An emptiness path inside an embedded list remains invalid. Collection shorthand excludes leaf emptiness (INVALID_FILTER_PATH); use an explicit quantified group to test a collection element. See empty values for the rules, the rejected forms and their codes, and the generated writes that the member rule reads as empty. A custom database adapter or SQL plugin receives them only after it declares them in queryFeatures too.
Field comparisons: use valuePath instead of value, for example products(stock: { operator: LTE, valuePath: "reorderLevel" }) { id } on a schema declaring those fields. It accepts compatible scalar/enum equality, numeric/String/matching-date ordering and literal String LIKE/STARTS_WITH/ENDS_WITH; scalar-list EQ means membership and NE is its complement. Missing/null compare equally, and null ordering/text is false. String ordering follows MongoDB's effective operation collation or PostgreSQL's C collation; exact EQ/NE and supported text predicates stay independent of collation. Both operands obey read permissions and target scopes. Relation terms resolve the right path from the related object; explicit quantified groups from their element. Collection shorthand compares the same child and can read its original containing record, while unrelated right collections remain invalid. Version 3.18.0 additionally supports embedded object list frames and structural $parent with queryFeatures.elementFrames: true; see embedded containers. The bundled adapters declare queryFeatures.valuePath: true; custom adapters must declare it with referenceEmptiness: true. value and valuePath are mutually exclusive, including value: null. See the full syntax, null/list rules and limits.
In aggregates, field comparisons preserve the row shared with collection facts; deeper filter-only collections do not multiply those facts. Existing target-scope restrictions on aggregate collection joins still apply. See the Barber aggregate example.
By default, all field-level filters are combined with implicit AND logic. For complex conditions requiring OR logic or nested combinations, use the AND and OR query arguments.
Return books in either the Sci-Fi or Fantasy category:
query {
books(
OR: [
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
]
) {
id
title
category
}
}
Flat field filters are always ANDed at the top level, making them ideal for scope/security conditions that cannot be bypassed by user OR logic:
query {
books(
rating: { operator: GTE, value: 7.0 }
OR: [
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
]
) {
id
title
}
}
This translates to: rating >= 7.0 AND (category = "Sci-Fi" OR category = "Fantasy").
query {
books(
OR: [
{ AND: [
{ conditions: [{ field: "rating", operator: GTE, value: 9.0 }] }
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
]}
{ AND: [
{ conditions: [{ field: "rating", operator: GTE, value: 8.0 }] }
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
]}
]
) {
id
title
}
}
This translates to: (rating >= 9.0 AND category = "Sci-Fi") OR (rating >= 8.0 AND category = "Fantasy").
Use the path parameter to filter on related entity fields:
query {
books(
OR: [
{ conditions: [{ field: "author", path: "name", operator: LIKE, value: "Adams" }] }
{ conditions: [{ field: "author", path: "name", operator: LIKE, value: "Pratchett" }] }
]
) {
id
title
author { name }
}
}
As an equivalent shorthand, you can pass the full dotted path in field and omit path:
query {
books(
OR: [
{ conditions: [{ field: "author.name", operator: LIKE, value: "Adams" }] }
{ conditions: [{ field: "author.name", operator: LIKE, value: "Pratchett" }] }
]
) {
id
title
author { name }
}
}
Both forms produce the same query. Multi-segment paths work too — e.g. field: "author.country.code" is equivalent to field: "author", path: "country.code". Supplying both a dotted field and a separate path is rejected as ambiguous.
You can freely combine the existing flat filter syntax with AND/OR groups. Flat filters and AND groups are all ANDed together at the top level:
query {
books(
rating: { operator: GTE, value: 7.0 }
author: { terms: [{ path: "country", operator: EQ, value: "UK" }] }
OR: [
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
]
) {
id
title
rating
category
author { name country }
}
}
This translates to: rating >= 7.0 AND author.country = "UK" AND (category = "Sci-Fi" OR category = "Fantasy"). The flat field filters (rating, author) use the existing syntax while the OR group uses the new QLFilterGroup syntax.
You can also combine flat filters with explicit AND groups for more complex logic:
query {
books(
rating: { operator: GTE, value: 5.0 }
AND: [
{
OR: [
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
]
}
{
OR: [
{ conditions: [{ field: "author", path: "country", operator: EQ, value: "UK" }] }
{ conditions: [{ field: "author", path: "country", operator: EQ, value: "US" }] }
]
}
]
) {
id
title
}
}
This translates to: rating >= 5.0 AND (category = "Sci-Fi" OR category = "Fantasy") AND (author.country = "UK" OR author.country = "US").
AND/OR filters are also available on collection fields (one-to-many relationships). The auto-generated resolvers for collection fields support the same AND and OR arguments:
query {
series {
seasons(
OR: [
{ conditions: [{ field: "year", operator: EQ, value: 2020 }] }
{ conditions: [{ field: "year", operator: EQ, value: 2021 }] }
]
) {
number
year
}
}
}
You can mix flat collection filters with AND/OR:
query {
series {
seasons(
number: { operator: GT, value: 1 }
OR: [
{ conditions: [{ field: "year", operator: EQ, value: 2020 }] }
{
AND: [
{ conditions: [{ field: "year", operator: GTE, value: 2022 }] }
{ conditions: [
# Equivalent to: { field: "episodes", path: "name", ... }
{ field: "episodes.name", operator: LIKE, value: "Final" }
] }
]
}
]
) {
number
year
episodes { name }
}
}
}
This translates to: number > 1 AND (year = 2020 OR (year >= 2022 AND episodes.name LIKE "Final")).
| Type | Fields | Description |
|---|---|---|
QLFilterGroup | AND: [QLFilterGroup], OR: [QLFilterGroup], conditions: [QLFilterCondition] | Recursive logical group |
QLFilterCondition | field: String!, operator: QLOperator, value: QLValue, path: String | Individual filter condition |
field identifies the entity field by name (e.g., "title", "author"). For object/relationship fields you can also pass the full dotted path here as a shorthand (e.g., "author.name", "author.country.name")path is required for object/relationship fields when field is just the top-level name (e.g., "name", "country.name"). Omit path if you used the dotted-field shorthand. Supplying both is an error.conditions in the same group are combined with ANDSimfinity.js now supports filtering collection fields (one-to-many relationships) using the same powerful query format. This allows you to filter related objects directly within your GraphQL queries.
Filter collection fields using the same operators and format as main queries:
query {
series {
seasons(number: { operator: EQ, value: 1 }) {
number
id
year
}
}
}
You can use complex filtering with nested object properties:
query {
series {
seasons(
year: { operator: GTE, value: 2020 }
episodes: {
terms: [
{
path: "name",
operator: LIKE,
value: "Pilot"
}
]
}
) {
number
year
episodes {
name
date
}
}
}
}
Combine multiple filter conditions for collection fields:
query {
series {
seasons(
number: { operator: GT, value: 1 }
year: { operator: BTW, value: [2015, 2023] }
) {
number
year
state
}
}
}
Filter deeply nested collections using dot notation:
query {
series {
seasons(
episodes: {
terms: [
{
path: "name",
operator: LIKE,
value: "Final"
}
]
}
) {
number
episodes {
name
date
}
}
}
}
Use array operations for collection fields:
query {
series {
seasons(
categories: { operator: IN, value: ["Drama", "Crime"] }
) {
number
categories
}
}
}
Note: Collection field filtering uses the exact same format as main query filtering, ensuring consistency across your GraphQL API. All available operators (EQ, NE, GT, LT, GTE, LTE, IN, NIN, IIN, CONTAINS_ALL, BTW, LIKE, ILIKE, STARTS_WITH, ISTARTS_WITH, ENDS_WITH, IENDS_WITH, IEQ, IS_EMPTY, IS_NOT_EMPTY) work with collection fields.
Use the extensions.relation field to define relationships between types:
const AuthorType = new GraphQLObjectType({
name: 'Author',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
books: {
type: new GraphQLList(BookType),
extensions: {
relation: {
connectionField: 'author',
displayField: 'title'
},
},
// resolve method automatically generated! 🎉
},
}),
});
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
// resolve method automatically generated! 🎉
},
}),
});
connectionField: (Required for referenced collections) The child field linking back to the parent. A referenced collection without it fails createSchema() with INVALID_MODEL (400), unless, in default MongoDB mode, the field has its own resolver or is readOnly; such a field builds, with a warning unless it is both, and rejects nested writes through it. For a single-object reference, this optionally overrides the stored ObjectId field; when omitted, model generation, writes, clears, and resolvers use the GraphQL field name.displayField: (Optional) Field to use for display in UI componentsembedded: (Optional) Whether the relation is embedded (default: false)🎉 NEW: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate.
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
// You had to manually write this
resolve(parent) {
return simfinity.getModel(AuthorType).findById(parent.author);
}
},
comments: {
type: new GraphQLList(CommentType),
extensions: {
relation: {
connectionField: 'bookId',
displayField: 'text'
},
},
// You had to manually write this too
resolve(parent) {
return simfinity.getModel(CommentType).find({ bookId: parent.id });
}
}
}),
});
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
// resolve method automatically generated! 🎉
},
comments: {
type: new GraphQLList(CommentType),
extensions: {
relation: {
connectionField: 'bookId',
displayField: 'text'
},
},
// resolve method automatically generated! 🎉
}
}),
});
findById() resolvers using the field name or connectionFieldfind() resolvers using the connectionField to query related objectsfind or findOne pre/post hook keep individual findOne reads, even when both operations share the same hook function: hooks can depend on the operation or filter shape. They also keep the read that generated mutations otherwise skip before a delete without onDelete and before an update that merges no embedded value. See the relationship batching contract.batchedCollections: true: PostgreSQL in createPostgres() or configure(), with one statement per 100 parents in a transaction that turns JIT off for that transaction only, and MongoDB in createMongoAdapter(), so the package-root runtime keeps one read per parent. Each parent keeps its own filters, sort and page, but unsorted and tied rows can come in another order. Batches save statements, commands and pool connections but read their parents one after another, and a PostgreSQL batch takes 4 round trips one after another on one connection, where per-parent reads take one each, concurrently. They pay off when the pool is saturated or much smaller than the number of parents per level, as with many parents with small child lists, and with free connections can be slower, even on a remote database, so measure before enabling them. See batched collection reads.// Connect all your types to Simfinity
simfinity.connect(null, AuthorType, 'author', 'authors');
simfinity.connect(null, BookType, 'book', 'books');
simfinity.connect(null, CommentType, 'comment', 'comments');
// Or use addNoEndpointType for types that don't need direct queries/mutations
simfinity.addNoEndpointType(AuthorType);
That's it! All relationship resolvers are automatically generated when you connect your types.
Use addNoEndpointType() for types that should be included in the GraphQL schema but don't need their own CRUD operations:
simfinity.addNoEndpointType(TypeName);
When to use addNoEndpointType() vs connect():
| Method | Use Case | Creates Endpoints | Use Example |
|---|---|---|---|
connect() | Types that need CRUD operations | ✅ Yes | User, Product, Order |
addNoEndpointType() | Types only used in relationships | ❌ No | Address, Settings, Director |
From the series-sample project:
// Director type - Used only as embedded data, no direct API access needed
const directorType = new GraphQLObjectType({
name: 'director',
fields: () => ({
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
country: { type: GraphQLString }
})
});
// Add to schema WITHOUT creating endpoints
simfinity.addNoEndpointType(directorType);
// Serie type - Has its own endpoints and embeds director data
const serieType = new GraphQLObjectType({
name: 'serie',
fields: () => ({
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
categories: { type: new GraphQLList(GraphQLString) },
director: {
type: new GraphQLNonNull(directorType),
extensions: {
relation: {
embedded: true, // Director data stored within serie document
displayField: 'name'
}
}
}
})
});
// Create full CRUD endpoints for series
simfinity.connect(null, serieType, 'serie', 'series');
Result:
addserie, updateserie, deleteserie mutations availableserie, series queries availableadddirector, director, directors endpoints (director is embedded)Usage:
mutation {
addserie(input: {
name: "Breaking Bad"
categories: ["crime", "drama", "thriller"]
director: {
name: "Vince Gilligan"
country: "United States"
}
}) {
id
name
director {
name
country
}
}
}
Use addNoEndpointType() for:
Use connect() for:
Referenced Relationships (default):
// Stores author ID in the book document
author: {
type: AuthorType,
extensions: {
relation: {
// connectionField not needed for single object relationships
embedded: false // This is the default
}
}
}
Embedded Relationships:
// Stores the full publisher object in the book document
publisher: {
type: PublisherType,
extensions: {
relation: {
embedded: true
}
}
}
Embedded types cannot contain each other, directly or through other embedded types: createSchema() throws INVALID_MODEL (Embedded cycle at …), even when a field of the cycle is readOnly, whenever Simfinity generates a model whose embedded fields reach the cycle, and always with referentialIntegrity: 'transactional'. In the default mode, a cycle that only supplied models reach is not checked, as before. Use a reference for recursive structures. An optional embedded object that holds no data reads as null when a required member would read as null, not counting id, readOnly, interface and union members, members with your own resolver and referenced collections; see MongoDB embedded objects in hydrated documents.
Query nested relationships with dot notation:
query {
books(author: {
terms: [
{
path: "country.name",
operator: EQ,
value: "England"
}
]
}) {
id
title
author {
name
country {
name
}
}
}
}
Link to existing objects:
mutation {
addBook(input: {
title: "New Book"
author: {
id: "existing_author_id"
}
}) {
id
title
author {
name
}
}
}
Create embedded objects:
mutation {
addBook(input: {
title: "New Book"
publisher: {
name: "Penguin Books"
location: "London"
}
}) {
id
title
publisher {
name
location
}
}
}
Work with arrays of related objects:
mutation {
updateBook(input: {
id: "book_id"
reviews: {
added: [
{ rating: 5, comment: "Amazing!" }
{ rating: 4, comment: "Good read" }
]
updated: [
{ id: "review_id", rating: 3 }
]
deleted: ["review_id_to_delete"]
}
}) {
id
title
reviews {
rating
comment
}
}
}
For non-embedded collections, each added, updated, or deleted child runs the target type's global middleware with the request context (for a programmatic saveObject() call, the context passed to it, possibly undefined). The operation and argument shapes match root mutations: save/update receive { input }, and delete receives { id } ({ id, version } for protected root and nested deletes). Updated and deleted children must already belong to the current parent in the transaction; a missing child raises NOT_VALID_ID, and a different parent raises ForbiddenError (FORBIDDEN). Ownership compares the stored link with the parent's stored identifier, so any accepted spelling of the parent ID works. Nested updates cannot move a child between parents. A rejection aborts the parent mutation and its child changes. To cap how many added, updated and deleted entries one mutation may carry across all nesting levels, call simfinity.configureMutationLimits({ maxNestedOperations }) at startup; see limit nested collection operations.
Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
const { validators } = require('@simtlix/simfinity-js');
const PersonType = new GraphQLObjectType({
name: 'Person',
fields: () => ({
id: { type: GraphQLID },
name: {
type: GraphQLString,
extensions: {
validations: validators.stringLength('Name', 2, 100)
}
},
email: {
type: GraphQLString,
extensions: {
validations: validators.email()
}
},
website: {
type: GraphQLString,
extensions: {
validations: validators.url()
}
},
age: {
type: GraphQLInt,
extensions: {
validations: validators.numberRange('Age', 0, 120)
}
},
price: {
type: GraphQLFloat,
extensions: {
validations: validators.positive('Price')
}
}
})
});
String Validators:
validators.stringLength(name, min, max) - Validates string length with min/max bounds (required for CREATE; omit a bound with undefined or null)validators.maxLength(name, max) - Validates maximum string lengthvalidators.pattern(name, regex, message) - Validates against a regex pattern (each value is tested from its first character, whatever the g/y flags; a pattern that is not a RegExp or a string throws TypeError)validators.email() - Validates email formatvalidators.url() - Validates URL formatNumber Validators:
validators.numberRange(name, min, max) - Validates number range (omit a bound with undefined or null)validators.positive(name) - Ensures number is positiveArray Validators:
validators.arrayLength(name, maxItems, itemValidator) - Validates array length and optionally each item; itemValidator is a validator, a helper result such as validators.maxLength('Tag', 20), or an array of themDate Validators:
validators.dateFormat(name, format) - Validates a date that JavaScript can parse; ISO (YYYY-MM-DD…) and slashed MM/DD/YYYY strings must be real calendar dates, and with 'YYYY-MM-DD' it also checks that shape (other formats are not checked and log a warning)validators.futureDate(name) - Ensures date is in the future, with the same date checks as dateFormat without a formatCREATE (save) and UPDATE operationsSimfinityError with appropriate messagesconst ProductType = new GraphQLObjectType({
name: 'Product',
fields: () => ({
id: { type: GraphQLID },
name: {
type: GraphQLString,
extensions: {
validations: validators.stringLength('Product Name', 3, 200)
}
},
sku: {
type: GraphQLString,
extensions: {
validations: validators.pattern('SKU', /^[A-Z0-9-]+$/, 'SKU must be uppercase alphanumeric with hyphens')
}
},
price: {
type: GraphQLFloat,
extensions: {
validations: validators.positive('Price')
}
},
tags: {
type: new GraphQLList(GraphQLString),
extensions: {
validations: validators.arrayLength('Tags', 10)
}
}
})
});
For custom validation logic, you can still write manual validators:
const { SimfinityError } = require('@simtlix/simfinity-js');
const validateAge = {
validate: async (typeName, fieldName, value, session) => {
if (value < 0 || value > 120) {
throw new SimfinityError(`Invalid age: ${value}`, 'VALIDATION_ERROR', 400);
}
}
};
const PersonType = new GraphQLObjectType({
name: 'Person',
fields: () => ({
id: { type: GraphQLID },
name: {
type: GraphQLString,
extensions: {
validations: {
save: [{
validate: async (typeName, fieldName, value, session) => {
if (!value || value.length < 2) {
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
}
}
}],
update: [{
validate: async (typeName, fieldName, value, session) => {
if (value && value.length < 2) {
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
}
}
}]
}
}
},
age: {
type: GraphQLInt,
extensions: {
validations: {
save: [validateAge],
update: [validateAge]
}
}
}
})
});
Validate objects as a whole:
const orderValidator = {
validate: async (typeName, args, modelArgs, session) => {
// Cross-field validation
if (modelArgs.deliveryDate < modelArgs.orderDate) {
throw new SimfinityError('Delivery date cannot be before order date', 'VALIDATION_ERROR', 400);
}
// Business rule validation
if (modelArgs.items.length === 0) {
throw new SimfinityError('Order must contain at least one item', 'BUSINESS_ERROR', 400);
}
}
};
const OrderType = new GraphQLObjectType({
name: 'Order',
extensions: {
validations: {
save: [orderValidator],
update: [orderValidator]
}
},
fields: () => ({
// ... fields
})
});
Create custom scalar types with built-in validation. The generated type names follow the pattern {name}_{baseScalarTypeName}.
Simfinity.js provides ready-to-use validated scalars for common patterns:
const { scalars } = require('@simtlix/simfinity-js');
const UserType = new GraphQLObjectType({
name: 'User',
fields: () => ({
id: { type: GraphQLID },
email: { type: scalars.EmailScalar }, // Type name: Email_String
website: { type: scalars.URLScalar }, // Type name: URL_String
age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
}),
});
Available Pre-built Scalars:
scalars.EmailScalar - Validates email format (Email_String)scalars.URLScalar - Validates URL format (URL_String)scalars.PositiveIntScalar - Validates positive integers (PositiveInt_Int)scalars.PositiveFloatScalar - Validates positive floats (PositiveFloat_Float)Create custom validated scalars with parameters:
const { scalars } = require('@simtlix/simfinity-js');
// Create a bounded string scalar (name length between 2-100 characters)
const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
// Create a bounded integer scalar (age between 0-120)
const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
// Create a bounded float scalar (rating between 0-10)
const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
// Create a pattern-based string scalar (phone number format)
const PhoneScalar = scalars.createPatternStringScalar(
'Phone',
/^\+?[\d\s\-()]+$/,
'Invalid phone number format'
);
// Use in your types
const PersonType = new GraphQLObjectType({
name: 'Person',
fields: () => ({
id: { type: GraphQLID },
name: { type: NameScalar }, // Type name: Name_String
age: { type: AgeScalar }, // Type name: Age_Int
rating: { type: RatingScalar }, // Type name: Rating_Float
phone: { type: PhoneScalar } // Type name: Phone_String
}),
});
Available Factory Functions:
scalars.createBoundedStringScalar(name, min, max) - String with length bounds (omit a bound with undefined or null)scalars.createBoundedIntScalar(name, min, max) - Integer with range validation (omit a bound with undefined or null)scalars.createBoundedFloatScalar(name, min, max) - Float with range validation (omit a bound with undefined or null)scalars.createPatternStringScalar(name, pattern, message) - String with regex pattern validation (same pattern rules as validators.pattern)You can also create custom scalars using createValidatedScalar directly:
const { GraphQLString, GraphQLInt } = require('graphql');
const { createValidatedScalar } = require('@simtlix/simfinity-js');
// Email scalar with validation (generates type name: Email_String)
const EmailScalar = createValidatedScalar(
'Email',
'A valid email address',
GraphQLString,
(value) => {
// The lookahead keeps matching linear on hostile input such as 'a@' + '.'.repeat(n) + ' '.
const emailRegex = /^[^\s@]+@(?=[^\s@]*$)[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error('Invalid email format');
}
}
);
// Positive integer scalar (generates type name: PositiveInt_Int)
const PositiveIntScalar = createValidatedScalar(
'PositiveInt',
'A positive integer',
GraphQLInt,
(value) => {
if (value <= 0) {
throw new Error('Value must be positive');
}
}
);
// Use in your types
const UserType = new GraphQLObjectType({
name: 'User',
fields: () => ({
id: { type: GraphQLID },
email: { type: EmailScalar }, // Type name: Email_String
age: { type: PositiveIntScalar }, // Type name: PositiveInt_Int
}),
});
The validation callback receives the base scalar's parsed value for variables and inline literals (for example, a Date from a DateTime base), and the resolver's value before serialization for output. Inline literal kinds follow the root of the chain of validated scalars, so a scalar built on PositiveIntScalar accepts integer literals, and Float-rooted scalars accept both 4 and 4.0. Custom root scalars decide their own literals, even with a hand-set baseScalarType storage hint. See validated scalars.
Create domain-specific error classes:
const { SimfinityError } = require('@simtlix/simfinity-js');
// Business logic error
class BusinessError extends SimfinityError {
constructor(message) {
super(message, 'BUSINESS_ERROR', 400);
}
}
// Authorization error
class AuthorizationError extends SimfinityError {
constructor(message) {
super(message, 'UNAUTHORIZED', 401);
}
}
// Not found error
class NotFoundError extends SimfinityError {
constructor(message) {
super(message, 'NOT_FOUND', 404);
}
}
To normalize errors in your GraphQL server, pass simfinity.buildErrorFormatter(callback) to its error-formatting option. For an error raised while resolving a field, it classifies the raised value, such as the one a resolver threw; other errors are classified as they are:
SimfinityError keeps its code and status.GraphQLError, such as a syntax, validation or variable error, keeps its message and extensions, including the http status Yoga adds. So does a GraphQLError raised while resolving a field, but only when it has its own string extensions.code or a SimfinityError cause. Its code is its own extensions.code, or BAD_REQUEST, and its status is its extensions.status, or 500 for INTERNAL_SERVER_ERROR and 400 otherwise; a SimfinityError cause, such as one thrown by a custom scalar, supplies them instead. Input rejected by Simfinity's validated scalars, such as an invalid EmailScalar value, is therefore BAD_REQUEST (400), and the hidden originalError of an application GraphQLError is never exposed. A code the server already set is kept, such as Yoga's GRAPHQL_PARSE_FAILED or Apollo Server's BAD_USER_INPUT.GraphQLError raised while resolving a field, such as one a resolver threw without a code or one graphql-js raised because it could not serialize or type-check the resolved value, becomes an InternalServerError (INTERNAL_SERVER_ERROR, status 500) that keeps the message and the error as its cause. Some graphql-js messages print the resolved value.Error becomes an InternalServerError (INTERNAL_SERVER_ERROR, status 500) that keeps the message and the error as its cause; a non-Error value becomes Unexpected error value with no cause. Under Yoga, whose executor turns a thrown non-Error value into an Error with the value's text, that text becomes the InternalServerError message instead.A GraphQL error without path is treated as a request error, including a scalar literal validation error that wraps another error with the same message. For subscriptions, mark unexpected source-stream failures with InternalServerError before the server wraps them: those wrappers can be indistinguishable from request errors. For a GraphQL error without a path, the formatter keeps an explicit internal cause for masking.
The callback receives the classified error and can return a replacement. To hide unexpected messages, branch on error instanceof simfinity.InternalServerError. Since 3.16.0, an error of the transaction mutation carries the position of its operation, which a callback that returns a new InternalServerError drops unless it copies operationIndex and operation; see errors of the transaction mutation. The formatter returns a GraphQLError whose originalError is the classified error. See buildErrorFormatter, server integration and server differences.
Declare extensions: { concurrency: { mode: 'optimistic' } } on entity types before their first createSchema() to opt into managed versions. Simfinity injects version: RecordVersion!, starts creates at 1 and requires the version read by the client in updates, state actions and deletes. Accepted updates advance it atomically, including version-only updates. Re-read and reapply on VERSION_CONFLICT or TRANSACTION_RETRY_EXCEEDED. See the concurrency guide for authorization, hooks, native writers, supplied sessions and storage migrations.
Protected children require version in nested updated entries and { id, version } in deleted: [RecordVersionRef], even when their parent is unprotected. Other collection deletes retain [ID]. RecordVersion and MAX_RECORD_VERSION are exported by core and the database facades.
Implement declarative state machine workflows:
const { GraphQLEnumType } = require('graphql');
const OrderState = new GraphQLEnumType({
name: 'OrderState',
values: {
PENDING: { value: 'PENDING' },
PROCESSING: { value: 'PROCESSING' },
SHIPPED: { value: 'SHIPPED' },
DELIVERED: { value: 'DELIVERED' },
CANCELLED: { value: 'CANCELLED' }
}
});
const OrderType = new GraphQLObjectType({
name: 'Order',
fields: () => ({
id: { type: GraphQLID },
customer: { type: GraphQLString },
state: { type: OrderState }
})
});
Use a GraphQL enum for the state field. PostgreSQL and other SQL backends require it: createSchema() rejects any other state field with INVALID_MODEL (400).
const stateMachine = {
initialState: { name: 'PENDING', value: 'PENDING' },
actions: {
process: {
from: { name: 'PENDING', value: 'PENDING' },
to: { name: 'PROCESSING', value: 'PROCESSING' },
description: 'Process the order',
action: async (args, session) => {
// Business logic for processing
console.log(`Processing order ${args.id}`);
// You can perform additional operations here
}
},
ship: {
from: { name: 'PROCESSING', value: 'PROCESSING' },
to: { name: 'SHIPPED', value: 'SHIPPED' },
description: 'Ship the order',
action: async (args, session) => {
// Business logic for shipping
console.log(`Shipping order ${args.id}`);
}
},
deliver: {
from: { name: 'SHIPPED', value: 'SHIPPED' },
to: { name: 'DELIVERED', value: 'DELIVERED' },
description: 'Mark as delivered'
},
cancel: {
from: { name: 'PENDING', value: 'PENDING' },
to: { name: 'CANCELLED', value: 'CANCELLED' },
description: 'Cancel the order'
}
}
};
simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);
The state machine automatically generates mutations for each action:
mutation {
process_order(input: {
id: "order_id"
}) {
id
state
customer
}
}
Important Notes:
state field is automatically read-only and managed by the state machineaction function is executed during transitionssimfinity.transitionObject('Order', 'process', { id }, session, context); see Programmatic Data AccessControllers provide fine-grained control over operations with lifecycle hooks:
const bookController = {
onSaving: async (doc, args, session, context) => {
// Before saving - doc is a Mongoose document
if (!doc.title || doc.title.trim().length === 0) {
throw new Error('Book title cannot be empty');
}
// Access user from context to set owner
if (context && context.user) {
doc.owner = context.user.id;
}
console.log(`Creating book: ${doc.title}`);
},
onSaved: async (doc, args, session, context) => {
// After saving - doc is a plain object
console.log(`Book saved: ${doc._id}`);
// Use context.user for transactional audit/outbox records on this session
},
onUpdating: async (id, doc, session, context) => {
// Before updating - doc contains only changed fields
// Validate user has permission to update
if (context && context.user && context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Only admins can update books', 'FORBIDDEN', 403);
}
console.log(`Updating book ${id}`);
},
onUpdated: async (doc, session, context) => {
// After updating - doc is the updated document
console.log(`Book updated: ${doc.title}`);
},
onDelete: async (doc, session, context) => {
// Before deleting - doc is the document to be deleted
// Validate user has permission to delete
if (context && context.user && context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Only admins can delete books', 'FORBIDDEN', 403);
}
console.log(`Deleting book: ${doc.title}`);
}
};
// Connect with controller
simfinity.connect(null, BookType, 'book', 'books', bookController);
onSaving(doc, args, session, context):
doc: Mongoose Document instance (not yet saved)args: Raw GraphQL mutation inputsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)onSaved(doc, args, session, context):
doc: Plain object of saved documentargs: Raw GraphQL mutation inputsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)onUpdating(id, doc, session, context):
id: Document ID being updateddoc: Plain object with only changed fieldssession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)onUpdated(doc, session, context):
doc: Full updated Mongoose document, after the parent update and nested collection writes have completedsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)onDelete(doc, session, context):
doc: Plain object of document to be deletedsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)The parent update executes before nested child operations. A malformed ID throws NOT_VALID_ID (400) before anything is written. For a type without embedded fields, onUpdating runs before that check and receives the raw ID, so a hook that looks the record up with findById() must handle a malformed value, which makes Mongoose throw a CastError. An update with no matching parent throws NOT_VALID_ID (404), without running child operations or onUpdated.
Lifecycle hooks, including onSaved and onUpdated, run before the transaction commits. They can run again after a transient transaction failure, with up to five retries after the first attempt; each retry waits a short random delay first. A write conflict that persists after the last retry fails with TRANSACTION_RETRY_EXCEEDED (409), with the driver error as error.cause. Keep additional database writes on the supplied session. For email or webhooks, write an outbox record in that transaction and deliver it after commit; external calls inside hooks cannot be rolled back. An uncertain commit result retries only the commit, up to five times, without repeating hooks. If the result remains uncertain, the error is returned and the database may already have committed; reconcile the outcome before repeating the operation.
Both database runtimes support optional controller onSaveCommitted(record, args, context), onUpdateCommitted(record, context), onDeleteCommitted(record, context) and onTransitionCommitted(record, action, context). A transition uses its own notification only; existing hooks keep their current transactional behavior.
const controller = {
onSaveCommitted: async (record, args, context) => {
await notifications.created(String(record._id ?? record.id), context?.requestId);
},
};
simfinity.configureAfterCommit({
onError: (error, details) => monitoring.report(error, details),
});
Register controller with connect(); notifications and monitoring are application services. A queue belongs to one transaction attempt, and only the confirmed attempt dispatches. Children precede their parent; callbacks are serial and awaited after cleanup is attempted. Callback failures are reported independently and never fail the committed mutation or retry its writes. Records are detached plain operation results, with ordinary objects/arrays/Dates copied and opaque scalar instances preserved. Context retains its original identity. No session is passed.
Lifecycle writes in registered mutations or SQL's withTransaction() join the owner's queue. Native externally owned sessions have no observed commit: callbacks are skipped with a warning. Cleanup failures after commit retain their existing rejection behavior while confirmed callbacks still run. No callback is a durable delivery or exactly-once guarantee; use an outbox and worker when delivery must survive process failure. See the full contract and configuration API.
The context parameter provides access to the GraphQL request context, which typically includes user information, request metadata, and other application-specific data. This is particularly useful for:
Example: Setting Owner on Creation
const documentController = {
onSaving: async (doc, args, session, context) => {
// Automatically set the owner to the current user
if (context && context.user) {
doc.owner = context.user.id;
}
}
};
Example: Role-Based Authorization
const adminOnlyController = {
onUpdating: async (id, doc, session, context) => {
if (!context || !context.user || context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
}
},
onDelete: async (doc, session, context) => {
if (!context || !context.user || context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
}
}
};
Note: When using saveObject programmatically (outside of GraphQL), the context parameter is optional and may be undefined. Always check for context existence before accessing its properties. Inside a hook, pass the hook's context to saveObject() so that controllers and the middleware of nested collection children see the request context.
Query scope allows you to automatically modify query arguments based on context (e.g., user permissions). This enables automatic filtering so that users can only see documents they're authorized to access. Scope functions are executed after middleware and before query execution, allowing you to append filter conditions to queries and aggregations.
Define scope in the type extensions, similar to how validations are defined:
const EpisodeType = new GraphQLObjectType({
name: 'episode',
extensions: {
validations: {
create: [validateEpisodeFields],
update: [validateEpisodeBusinessRules]
},
scope: {
find: async ({ type, args, operation, context }) => {
// Modify args in place to add filter conditions
args.AND = [
...(args.AND || []),
{ conditions: [{ field: 'owner', path: 'id', operator: 'EQ', value: context.user.id }] },
];
},
aggregate: async ({ type, args, operation, context }) => {
// Apply same scope to aggregate queries
args.AND = [
...(args.AND || []),
{ conditions: [{ field: 'owner', path: 'id', operator: 'EQ', value: context.user.id }] },
];
},
get_by_id: async ({ type, args, operation, context }) => {
// For get_by_id, scope is automatically merged with id filter
args.owner = {
terms: [
{
path: 'id',
operator: 'EQ',
value: context.user.id
}
]
};
}
}
},
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
owner: {
type: new GraphQLNonNull(simfinity.getType('user')),
extensions: {
relation: {
connectionField: 'owner',
displayField: 'name'
}
}
}
})
});
Scope functions for find operations modify the query arguments that are passed to buildQuery. The modified arguments are automatically used to filter results:
const DocumentType = new GraphQLObjectType({
name: 'Document',
extensions: {
scope: {
find: async ({ type, args, operation, context }) => {
// Only show documents owned by the current user
args.AND = [
...(args.AND || []),
{ conditions: [{ field: 'owner', path: 'id', operator: 'EQ', value: context.user.id }] },
];
}
}
},
fields: () => ({
id: { type: GraphQLID },
title: { type: GraphQLString },
owner: {
type: new GraphQLNonNull(simfinity.getType('user')),
extensions: {
relation: {
connectionField: 'owner',
displayField: 'name'
}
}
}
})
});
Result: All documents queries will automatically filter to only return documents where owner.id equals context.user.id.
Scope functions for aggregate operations work the same way, ensuring aggregation queries also respect the scope:
const OrderType = new GraphQLObjectType({
name: 'Order',
extensions: {
scope: {
aggregate: async ({ type, args, operation, context }) => {
// Only aggregate orders for the current user's organization
args.AND = [
...(args.AND || []),
{ conditions: [{ field: 'organization', path: 'id', operator: 'EQ', value: context.user.organizationId }] },
];
}
}
},
fields: () => ({
// ... fields
})
});
Result: All orders_aggregate queries will automatically filter to only aggregate orders from the user's organization.
For get_by_id operations, scope functions modify a temporary query arguments object that includes the id filter. The system automatically combines the id filter with scope filters:
const PrivateDocumentType = new GraphQLObjectType({
name: 'PrivateDocument',
extensions: {
scope: {
get_by_id: async ({ type, args, operation, context }) => {
// Ensure user can only access their own documents
args.owner = {
terms: [
{
path: 'id',
operator: 'EQ',
value: context.user.id
}
]
};
}
}
},
fields: () => ({
// ... fields
})
});
Result: When querying privatedocument(id: "some_id"), the system will:
null if the document exists but doesn't match the scopeextensions.scope must be a plain object whose keys are find, get_by_id or aggregate and whose values are functions. Omit a key to leave that operation unscoped. Any other shape, such as a misspelled getById key, a key set to undefined ({ find: undefined }), an array, a bare function or scope: undefined, throws INVALID_SCOPE (500) from connect(), addNoEndpointType() or createSchema(). Non-enumerable keys are checked too. A scope changed into an invalid shape later fails every read of that type instead of being skipped.
Scope functions receive the same parameters as middleware for consistency:
{
type, // Type information (model, gqltype, controller, etc.)
args, // GraphQL arguments passed to the operation (modify this object)
operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
context // GraphQL context object (includes request info, user data, etc.)
}
When modifying args in scope functions, use the appropriate filter structure:
For scalar fields:
args.fieldName = {
operator: 'EQ',
value: 'someValue'
};
For object/relation fields (QLTypeFilterExpression):
args.relationField = {
terms: [
{
path: 'fieldName',
operator: 'EQ',
value: 'someValue'
}
]
};
Assigning a field's filter replaces any filter the caller sent on that field. In find and aggregate scopes, append the restriction to args.AND instead, as the examples above do; with an adapter that supports emptiness through references and collections, replacing a client's IS_EMPTY or IS_NOT_EMPTY condition on a reference or a collection fails the request with DETACHED_CLIENT_FILTER (500).
Here's a complete example showing scope for all query operations:
const EpisodeType = new GraphQLObjectType({
name: 'episode',
extensions: {
validations: {
save: [validateEpisodeFields],
update: [validateEpisodeBusinessRules]
},
scope: {
find: async ({ type, args, operation, context }) => {
// Only show episodes from seasons the user has access to
args.AND = [
...(args.AND || []),
{ conditions: [{ field: 'season', path: 'owner.id', operator: 'EQ', value: context.user.id }] },
];
},
aggregate: async ({ type, args, operation, context }) => {
// Apply same scope to aggregations
args.AND = [
...(args.AND || []),
{ conditions: [{ field: 'season', path: 'owner.id', operator: 'EQ', value: context.user.id }] },
];
},
get_by_id: async ({ type, args, operation, context }) => {
// Ensure user can only access their own episodes
args.owner = {
terms: [
{
path: 'id',
operator: 'EQ',
value: context.user.id
}
]
};
}
}
},
fields: () => ({
id: { type: GraphQLID },
number: { type: GraphQLInt },
name: { type: GraphQLString },
season: {
type: new GraphQLNonNull(simfinity.getType('season')),
extensions: {
relation: {
connectionField: 'season',
displayField: 'number'
}
}
},
owner: {
type: new GraphQLNonNull(simfinity.getType('user')),
extensions: {
relation: {
connectionField: 'owner',
displayField: 'name'
}
}
}
})
});
args object directly, appending restrictions, for example to args.AND, instead of assigning over the caller's filters. Replacing or rebuilding a marked client emptiness or collection construct on a scoped relation chain fails the request with DETACHED_CLIENT_FILTER (500); see keep the client's filter objectsQLFilter for scalars, QLTypeFilterExpression for relations)find, aggregate, and get_by_id operationsget_by_id middleware and scope; collection relations run its find middleware and scope with the same request context. A separate database predicate preserves the referenced ID or parent connection even when filters are changed, including by scope functions. A scoped-out single relation returns null; a collection omits scoped-out children.groupId or fact path crosses a non-embedded single reference to a type with a find scope, that scope runs with empty args and its conditions are ANDed below the relation path (tenant becomes author.tenant), into the query or, for a path used only in AND/OR group conditions, into each group that uses it. Rows whose referenced record is scoped out are omitted, also when sorting or grouping through the reference. A client's IS_EMPTY and IS_NOT_EMPTY conditions apply the target's find scope inside the condition instead, so a scoped-out record reads as a missing one; see emptiness through references and collections. On adapters that declare quantifiers, collection filters apply frame scopes and targets whose scopes filter through collections are supported through non-list prefixes. The 403 remains for /fact paths into scoped collections, references reached inside embedded lists when their scope restricts the caller, embedded-list prefixes, and undeclared adapters. References inside a frame use scoped joins. Paths added by middleware and scopes are trusted.Simfinity.js provides production-grade centralized GraphQL authorization supporting RBAC/ABAC, function-based rules, declarative policy expressions (JSON AST), wildcard permissions, and configurable default policies. It ships as a native Envelop plugin for GraphQL Yoga (recommended) and also supports the legacy graphql-middleware approach.
const { auth } = require('@simtlix/simfinity-js');
const { createYoga } = require('graphql-yoga');
const { createAuthPlugin, requireAuth, requireRole } = auth;
// Define your permission schema
// Simfinity names its roots `RootQueryType` and `Mutation`
const permissions = {
RootQueryType: {
series: requireAuth(),
seasons: requireAuth(),
},
Mutation: {
deleteserie: requireRole('admin'),
deletestar: requireRole('admin'),
},
serie: {
'*': requireAuth(), // Wildcard: all fields require auth
},
};
// Create the Envelop auth plugin and pass it to your server
const authPlugin = createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' });
const yoga = createYoga({ schema, plugins: [authPlugin] });
The permission schema defines authorization rules per type and field:
const permissions = {
// Root types: Simfinity names its roots `RootQueryType` and `Mutation`
RootQueryType: {
fieldName: ruleOrRules,
},
// Object types
TypeName: {
'*': wildcardRule, // Applies to all fields unless overridden
fieldName: specificRule, // Overrides wildcard for this field
},
};
Resolution Order:
permissions[TypeName][fieldName]permissions[TypeName]['*']The same resolution applies to each field that a generated list, aggregate or collection-relationship field names in its filter, AND/OR, list sort, groupId or fact paths, following related types (posts(author: { terms: [{ path: "email" }] }) checks Post.author and User.email). These path rules run before the resolver with an undefined parent and empty args, so parent-dependent rules such as isOwner deny them. info.fieldName, info.parentType and info.returnType identify the path field being checked, so rules that select permissions by field or type keep applying; other operation metadata, including path and fieldNodes, still describes the invoking query. A custom rule that throws anything other than a Simfinity or GraphQL error also denies the path. Aggregate sort terms name result keys and are not checked; paths added by middleware or scopes are trusted.
Rule Types:
(parent, args, ctx, info) => boolean | void | Promise<boolean | void>Permission maps must be plain objects (including objects with a null prototype). Only their own type and field entries are used. The default policy applies only when an exact field entry and wildcard are both absent. defaultPolicy must be exactly 'ALLOW' or 'DENY'; omitting it defaults to 'DENY'.
createAuthPlugin, createAuthMiddleware, and createFieldMiddleware throw TypeError when configured with an invalid rule, empty rule array, malformed policy expression, permission map, or default policy. If a permission map has a Query key but the schema's query root has another name, such as Simfinity's RootQueryType, createAuthPlugin logs a warning once. A configured null, undefined, or unknown object never falls back to ALLOW. Use allow() or true to grant access explicitly, or omit the entry to use the default policy.
Rule Semantics:
return true or return void → allowreturn false → denythrow Error → deny with errorOnly true and undefined allow access. Other results, including null, 0, an empty string, objects, and other truthy values, deny access. This contract is identical for direct rules, composeRules, anyRule, and createRule, including async results. composeRules, anyRule, and createRule require function arguments, and composeRules and anyRule require at least one rule: use allow() for an intentional grant. anyRule may continue after a denial or error to a different granting rule.
Simfinity.js provides reusable rule builders:
const { auth } = require('@simtlix/simfinity-js');
const {
resolvePath, // Utility to resolve dotted paths in objects
requireAuth, // Requires ctx.user to exist
requireRole, // Requires specific role(s)
requirePermission, // Requires specific permission(s)
composeRules, // Combine rules (AND logic)
anyRule, // Combine rules (OR logic)
isOwner, // Check resource ownership
allow, // Always allow
deny, // Always deny
createRule, // Create custom rule
} = auth;
Requires the user to be authenticated. Supports custom user paths in context:
Paths in requireAuth, requireRole, requirePermission and isOwner are dotted strings or synchronous extractor functions. An async extractor throws TypeError when the helper is created. A path that yields a promise at runtime, such as an un-awaited ctx.user, denies with TypeError instead of granting access; await the user in your context factory.
const permissions = {
RootQueryType: {
// Default: checks ctx.user
me: requireAuth(),
// Custom path: checks ctx.auth.currentUser
profile: requireAuth('auth.currentUser'),
// Deep path: checks ctx.session.data.user
settings: requireAuth('session.data.user'),
},
};
Requires the user to have a specific role. Supports custom paths (dotted strings or synchronous extractors):
The required role must be a nonempty string or nonempty array of nonempty strings; invalid configuration throws TypeError. The user's single role value must match an allowed string exactly. The helper copies the required list when created, so later changes to the original array do not alter the rule.
const permissions = {
RootQueryType: {
// Default: checks ctx.user.role
adminDashboard: requireRole('ADMIN'),
modTools: requireRole(['ADMIN', 'MODERATOR']), // Any of these roles
// Custom paths: checks ctx.auth.user.profile.role
superAdmin: requireRole('SUPER_ADMIN', {
userPath: 'auth.user',
rolePath: 'profile.role',
}),
},
};
Requires the user to have specific permission(s). Supports custom paths (dotted strings or synchronous extractors):
The required permission must be a nonempty string or nonempty array of nonempty strings; invalid configuration throws TypeError. The helper copies the required list when created, while user claims are checked on every call. The user's claim must be an array of nonempty strings. Entries match exactly; only a standalone '*' entry grants every permission. A claim such as 'posts:read' must be supplied as ['posts:read']. String claims, substrings, and embedded wildcard characters do not grant access.
const permissions = {
Mutation: {
// Default: checks ctx.user.permissions
deletePost: requirePermission('posts:delete'),
manageUsers: requirePermission(['users:read', 'users:write']), // All required
// Custom paths: checks ctx.session.user.access.grants
admin: requirePermission('admin:all', {
userPath: 'session.user',
permissionsPath: 'access.grants',
}),
},
};
Combines multiple rules with AND logic (all must pass):
const permissions = {
Mutation: {
updatePost: composeRules(
requireAuth(),
requireRole('EDITOR'),
async (post, args, ctx) => post.authorId === ctx.user.id,
),
},
};
Combines multiple rules with OR logic (any must pass):
const permissions = {
Post: {
content: anyRule(
requireRole('ADMIN'),
async (post, args, ctx) => post.authorId === ctx.user.id,
),
},
};
Checks if the authenticated user owns the resource:
IDs must be nonempty strings, finite numbers (including zero), or Mongoose ObjectId instances. Numbers compare by their string representation, and ObjectIds compare by hexadecimal value. Missing/null IDs, empty strings, arrays, booleans, and arbitrary objects deny access. Use the path or synchronous extractor arguments to obtain a supported ID from a custom identity object.
const permissions = {
Post: {
'*': composeRules(
requireAuth(),
isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
),
},
};
For declarative rules, use JSON AST policy expressions:
const permissions = {
Post: {
content: {
anyOf: [
{ eq: [{ ref: 'parent.published' }, true] },
{ eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
],
},
},
};
Supported Operators:
| Operator | Description | Example |
|---|---|---|
eq | Equals | { eq: [{ ref: 'parent.status' }, 'active'] } |
in | Value in array | { in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] } |
allOf | All must be true (AND) | { allOf: [expr1, expr2] } |
anyOf | Any must be true (OR) | { anyOf: [expr1, expr2] } |
not | Negation | { not: { eq: [{ ref: 'parent.deleted' }, true] } } |
References:
Use { ref: 'path' } to reference values:
parent.* - Parent resolver result (the object being resolved)args.* - GraphQL argumentsctx.* - GraphQL contextSecurity:
parent, args, and ctx roots are allowed__proto__, prototype, or constructor segments are invalideq and in require exactly two operands; in requires an array or a reference resolving to an array. A literal in array may contain only strings, finite numbers, bigints, booleans, null and MongoDB ObjectIds, and literal operands cannot contain nested { ref } values, because literals are never resolved. A rule keeps the list items it validated. allOf and anyOf require arrays of valid expressions; not requires a valid expressionanyOfTypeError; isPolicyExpression validates the complete AST, and direct evaluateExpression calls return false for malformed ASTsref to a literal later does not turn it into a reference. Date literals are copied and compare by time; other object literals keep their identity for equalitynot or repeated negation. They propagate through allOf and implicit AND. A separate valid anyOf branch can still grant access, such as a published post without an authenticated usernull, false, 0, and empty-string comparison values remain valid; equality is strict. MongoDB ObjectId values compare by their hexadecimal string, so an ObjectId matches the same ID as a string or as another ObjectId instance. Values of different types never match (such as '42' and 42), and such a comparison is invalid, so not cannot turn it into a grant; null remains comparable with any value. in compares each array item the same way for plain arrays and Mongoose arrays; when no item matches, it is invalid if any non-null item has a different type than the value, or if the value is a list or plain object. Use requireAuth() or isOwner() for identity checks rather than treating two explicit null IDs as ownershipThe recommended way to use the auth system is via the Envelop plugin, which works natively with GraphQL Yoga and any Envelop-based server. The plugin wraps resolvers in-place without rebuilding the schema, avoiding compatibility issues. Every schema type must come from the graphql module the plugin uses: a type from another copy, such as a second installed version or its ESM and CommonJS builds loaded together, makes onSchemaChange throw TypeError before any field is wrapped. A plugin instance wraps each field once, even when several schemas share the same types. In a schema that only other plugin instances processed, its wrappers defer to theirs; a schema no instance processed keeps enforcing every wrapper's rules. Fields with no rule under ALLOW and no filter, sort or aggregation paths keep their original resolver. Simfinity's extensions metadata types are not wrapped unless the permission map names them. A field whose rules are all synchronous, or an unwrapped field with no rule under ALLOW, starts its resolver before the rules of the following sibling fields run.
const { createYoga } = require('graphql-yoga');
const { createServer } = require('http');
const simfinity = require('@simtlix/simfinity-js');
const { auth } = simfinity;
const { createAuthPlugin, requireAuth, requireRole, composeRules, isOwner, deny } = auth;
// Define your types and connect them
simfinity.connect(null, SerieType, 'serie', 'series');
simfinity.connect(null, SeasonType, 'season', 'seasons');
simfinity.connect(null, StarType, 'star', 'stars');
// Create base schema
const schema = simfinity.createSchema();
// Define permissions
// Simfinity names its roots `RootQueryType` and `Mutation`
const permissions = {
RootQueryType: {
series: requireAuth(),
seasons: requireAuth(),
stars: requireAuth(),
},
Mutation: {
addserie: requireAuth(),
updateserie: composeRules(requireAuth(), isOwner('createdBy')),
deleteserie: requireRole('admin'),
deletestar: requireRole('admin'),
},
serie: {
'*': requireAuth(),
},
season: {
'*': requireAuth(),
},
};
// Create auth plugin
const authPlugin = createAuthPlugin(permissions, {
defaultPolicy: 'ALLOW',
debug: false,
});
// Setup Yoga with the auth plugin
const yoga = createYoga({
schema,
plugins: [authPlugin],
context: (req) => ({
user: req.user, // Set by your authentication layer
}),
});
const server = createServer(yoga);
server.listen(4000);
Since 3.16.0, the operations of a transaction mutation get the rules of their roots from the plugin, on every attempt and before their middleware, so give rules to Mutation.transaction and to each operation key (under DENY, also to TransactionResult and the returned types); the id of a result item follows the id rule of its type, and an operation whose generated root has an extensions.simfinityMutation marker that is frozen or otherwise not extensible and carries no authorization entries is refused with FORBIDDEN. Use the auth namespace of the same release and facade as the runtime that built the schema (simfinity.auth, as above): a plugin from an earlier release records no operation checks. Do not change a permission map once the plugin has processed a schema. See the authorization guide.
Deprecated:
applyMiddlewarefromgraphql-middlewarerebuilds the schema viamapSchema, which can cause"Schema must contain uniquely named types"errors with Simfinity schemas. UsecreateAuthPluginwith GraphQL Yoga / Envelop instead. Simfinity does not installgraphql-middleware; add it to your application to use this example.createFieldMiddlewarereturns the same middleware function ascreateAuthMiddleware, so wildcard rules and the default policy apply to every field. Since 3.16.0, both deny the generatedtransactionfield withForbiddenError, because they cannot apply the rules of its operations.
const { applyMiddleware } = require('graphql-middleware');
const simfinity = require('@simtlix/simfinity-js');
const { auth } = simfinity;
const { createAuthMiddleware, requireAuth, requireRole } = auth;
const baseSchema = simfinity.createSchema();
const authMiddleware = createAuthMiddleware(permissions, {
defaultPolicy: 'DENY',
});
const schema = applyMiddleware(baseSchema, authMiddleware);
const plugin = createAuthPlugin(permissions, {
defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
debug: false, // Enable debug logging
});
| Option | Type | Default | Description |
|---|---|---|---|
defaultPolicy | 'ALLOW' | 'DENY' | 'DENY' | Policy when no rule matches |
debug | boolean | false | Log authorization decisions |
The auth middleware uses Simfinity error classes:
const { auth } = require('@simtlix/simfinity-js');
const { UnauthenticatedError, ForbiddenError } = auth;
// UnauthenticatedError: code 'UNAUTHENTICATED', status 401
// ForbiddenError: code 'FORBIDDEN', status 403
Custom error handling in rules:
const permissions = {
Mutation: {
deleteserie: async (parent, args, ctx) => {
if (!ctx.user) {
throw new auth.UnauthenticatedError('Please log in');
}
if (ctx.user.role !== 'admin') {
throw new auth.ForbiddenError('Only admins can delete series');
}
return true;
},
},
};
defaultPolicy: 'DENY' for security'*' rules provide baseline security per typerequireAuth(), requireRole() over custom functionsMiddlewares provide a powerful way to intercept and process all GraphQL operations before they execute. Use them for cross-cutting concerns like authentication, logging, validation, and performance monitoring.
Register middlewares using simfinity.use(). Middlewares execute in the order they're registered. use() accepts only functions; any other value throws INVALID_MIDDLEWARE (500):
// Basic logging middleware
simfinity.use(async (params, next) => {
console.log(`Executing ${params.operation} on ${params.type?.gqltype.name || params.entry}`);
await next();
});
Each middleware receives a params object containing:
Generated non-embedded relationship reads and nested collection mutations also invoke middleware for the related type. Middleware and scope callbacks are awaited. Nested child operations use the same argument shapes and request context as their root equivalents; review middleware that previously assumed it ran only once per root operation.
simfinity.use(async (params, next) => {
// params object contains:
const {
type, // Registered type metadata: gqltype, model, controller, etc. Absent for custom queries and mutations
args, // GraphQL arguments passed to the operation
operation, // Operation type: 'save', 'update', 'delete', 'get_by_id', 'find', 'aggregate', 'state_changed', 'custom_mutation', 'custom_query'
context, // GraphQL context object (includes request info, user data, etc.)
actionName, // For state machine actions (only present for state_changed operations)
actionField, // State machine action details (only present for state_changed operations)
entry // Custom operation name (custom_mutation or custom_query), or the view name for a view's find
} = params;
// Await next() to continue the middleware chain
await next();
});
simfinity.use(async (params, next) => {
const { context, operation, type } = params;
// Skip authentication for read operations
if (operation === 'get_by_id' || operation === 'find') {
await next();
return;
}
// Check if user is authenticated
if (!context.user) {
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
}
// Check permissions for specific types
if (type?.gqltype.name === 'User' && context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
}
await next();
});
simfinity.use(async (params, next) => {
const { operation, type } = params;
console.log(`[${new Date().toISOString()}] Preparing ${operation}${type ? ` on ${type.gqltype.name}` : ''}`);
// Continue the middleware chain. The database operation runs after the whole chain returns,
// so measure complete request duration in your GraphQL server's execution hooks instead.
await next();
});
simfinity.use(async (params, next) => {
const { operation, args, type } = params;
// Validate input for save operations
if (operation === 'save' && args.input) {
// Trim string fields
Object.keys(args.input).forEach(key => {
if (typeof args.input[key] === 'string') {
args.input[key] = args.input[key].trim();
}
});
// Validate required business rules
if (type?.gqltype.name === 'Book' && args.input.title && args.input.title.length < 3) {
throw new simfinity.SimfinityError('Book title must be at least 3 characters', 'VALIDATION_ERROR', 400);
}
}
await next();
});
const requestCounts = new Map();
simfinity.use(async (params, next) => {
const { context, operation } = params;
const userId = context.user?.id || context.ip;
const now = Date.now();
const windowMs = 60000; // 1 minute
const maxRequests = 100;
// Only apply rate limiting to mutations
if (operation === 'save' || operation === 'update' || operation === 'delete') {
const userRequests = requestCounts.get(userId) || [];
const recentRequests = userRequests.filter(time => now - time < windowMs);
if (recentRequests.length >= maxRequests) {
throw new simfinity.SimfinityError('Rate limit exceeded', 'TOO_MANY_REQUESTS', 429);
}
recentRequests.push(now);
requestCounts.set(userId, recentRequests);
}
await next();
});
simfinity.use(async (params, next) => {
const { operation, type, args, context } = params;
// Log all mutations for audit purposes
if (operation === 'save' || operation === 'update' || operation === 'delete') {
const auditEntry = {
timestamp: new Date(),
user: context.user?.id,
operation,
type: type?.gqltype.name,
entityId: args.id || 'new',
data: operation === 'delete' ? null : args.input,
ip: context.ip,
userAgent: context.userAgent
};
// Save to audit log (could be database, file, or external service)
console.log('AUDIT:', JSON.stringify(auditEntry));
}
await next();
});
Middlewares execute in registration order. Each middleware awaits next() to continue the chain:
// Middleware 1: Authentication
simfinity.use(async (params, next) => {
console.log('1. Checking authentication...');
// Authentication logic here
await next(); // Continue to next middleware
});
// Middleware 2: Authorization
simfinity.use(async (params, next) => {
console.log('2. Checking permissions...');
// Authorization logic here
await next(); // Continue to next middleware
});
// Middleware 3: Logging
simfinity.use(async (params, next) => {
console.log('3. Logging request...');
// Logging logic here
await next(); // The operation runs after the chain returns
});
The rest of the chain runs at most once, even if a middleware calls next() twice. The runtime awaits it even if a middleware calls next() without awaiting it, and its errors still cancel the operation instead of becoming unhandled rejections. Call next() before the middleware returns or its promise settles: a next() called later, for example from setTimeout(next), does nothing, so the remaining middleware is skipped and the operation still runs.
Middlewares can throw errors to stop the operation:
simfinity.use(async (params, next) => {
const { context, operation } = params;
// Validation logic
if (!context.user && operation !== 'find') {
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
}
try {
await next(); // Continue only if validation passes
} catch (error) {
// An error from a later middleware still cancels the operation, even when caught here.
console.error('Middleware chain failed', error);
throw error;
}
});
Execute middleware logic conditionally based on operation type or context:
simfinity.use(async (params, next) => {
const { operation, type, context } = params;
// Only apply to specific types
if (type?.gqltype.name === 'SensitiveData') {
// Special handling for sensitive data
if (!context.user?.hasHighSecurity) {
throw new simfinity.SimfinityError('High security clearance required', 'FORBIDDEN', 403);
}
}
// Only apply to mutation operations
if (['save', 'update', 'delete', 'state_changed'].includes(operation)) {
// Mutation-specific logic
console.log(`Mutation ${operation} executing...`);
}
await next();
});
next(): Call it before the middleware returns. Omitting next(), or calling it later from a callback, skips the remaining middleware, but the operation still runs. Throw an error to cancel itnext() does not let the operation continue; use it for loggingControl field behavior with extensions:
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: GraphQLID },
title: {
type: GraphQLString,
extensions: {
unique: true, // Creates unique index in MongoDB
readOnly: true // Excludes from input types
}
},
isbn: {
type: GraphQLString,
extensions: {
unique: true
}
},
readers: {
type: GraphQLInt,
args: { since: { type: new GraphQLNonNull(GraphQLString) } },
extensions: {
virtual: true // Computed by its resolver; never stored, filtered or written
},
resolve: (book, { since }) => countReaders(book._id ?? book.id, since)
}
})
});
A virtual field (extensions: { virtual: true }) is computed by its own resolver on every read. No backend stores it, generated inputs leave it out, and list queries have no filter, sort or aggregation argument or path for it: client paths through it fail with FORBIDDEN_FILTER_PATH. Clients read it through the type's generated queries, after the type's middleware and scope, so use one for a read that the filter language cannot express. Its result can be any output type, registered or not, without relation. Generated MCP tools give a virtual field with a required argument its own read tool, here book_readers; extensions: { virtual: { mcp: 'select' } } lets list tools select a field without required arguments, and { mcp: false } turns the tool off. See virtual fields.
Register a custom query for a read whose result is not a stored record: a computation without storage, such as a price preview or an estimate; data from another source, such as a search index or another service; a permission evaluation; or a result assembled from several sources, such as a federated search or an export. Reads of stored records belong in generated queries, query scopes, aggregates and virtual fields.
This price preview prices a cart without storing anything. pricing stands for your application's pricing service, which reads a price list and tax rules:
import {
GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList,
GraphQLNonNull, GraphQLObjectType, GraphQLString,
} from 'graphql';
const CartLineInput = new GraphQLInputObjectType({
name: 'CartLineInput',
fields: {
sku: { type: new GraphQLNonNull(GraphQLString) },
quantity: { type: new GraphQLNonNull(GraphQLInt) },
},
});
const PricePreviewInput = new GraphQLInputObjectType({
name: 'PricePreviewInput',
fields: { lines: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(CartLineInput))) } },
});
const PricePreview = new GraphQLObjectType({
name: 'PricePreview',
fields: {
subtotal: { type: new GraphQLNonNull(GraphQLFloat) },
tax: { type: new GraphQLNonNull(GraphQLFloat) },
total: { type: new GraphQLNonNull(GraphQLFloat) },
},
});
simfinity.registerCustomQuery(
'pricePreview',
'Price a cart without storing it',
PricePreviewInput,
PricePreview,
(input, context) => pricing.preview(input.lines, context.user),
);
const schema = simfinity.createSchema();
query PricePreview($input: PricePreviewInput!) {
pricePreview(input: $input) { subtotal tax total }
}
The field belongs to RootQueryType, so clients send it in a query document with other query fields. The callback receives (input, context). Simfinity opens no transaction and passes no session; a callback that needs one opens it with simfinity.withTransaction(null, callback) on PostgreSQL or the Mongoose connection's transaction() on MongoDB. Middleware runs first with operation: 'custom_query', the authorization plugin authorizes the field under RootQueryType, and MCP publishes it as a read-only tool. Select custom queries with the options object of createSchema(), such as createSchema(null, null, null, { includedCustomQueries: ['pricePreview'] }). A schema without mutation fields has no Mutation root.
See the custom query guide for selection, transactions and authorization, and the API reference for the full contract.
Use registerView(name, { type, description, params, where }) for a frequent list read with typed params and a fixed predicate. A view returns the base type's records through its generated list path and find scope. Clients can add filters, sorting and pagination.
This documentation example uses the registered Barber bookingType and its scope. Register the view before createSchema(). The Barber applications still pin their published runtime version.
import { graphql, GraphQLString } from 'graphql';
import { simfinity } from './runtime.js';
simfinity.registerView('upcomingBookings', {
type: bookingType,
description: 'Confirmed bookings from a date on, default today, that the caller may read. For clock order pass sort scheduledDate ASC, startTime ASC.',
params: {
from: {
type: GraphQLString,
description: 'First date included, YYYY-MM-DD. Defaults to today (UTC).',
},
},
where: ({ params }) => [{
conditions: [
{ field: 'state', operator: 'EQ', value: 'CONFIRMED' },
{
field: 'scheduledDate',
operator: 'GTE',
value: params.from ?? new Date().toISOString().slice(0, 10),
},
],
}],
});
const schema = simfinity.createSchema();
Initialize the backend before serving operations, as in database setup. Call the field through GraphQL with the same trusted request context as any generated query:
const result = await graphql({
schema,
source: `query Upcoming($from: String) {
upcomingBookings(
from: $from
sort: { terms: [
{ field: "scheduledDate", order: ASC }
{ field: "startTime", order: ASC }
] }
pagination: { page: 1, size: 20, count: true }
) {
scheduledDate
startTime
state
}
}`,
variableValues: { from: '2026-11-01' },
contextValue: context,
});
With one matching booking visible to that caller, an illustrative result is:
{
"data": {
"upcomingBookings": [
{ "scheduledDate": "2026-11-02", "startTime": "09:00", "state": "CONFIRMED" }
]
}
}
The count is available as context.count, here 1. Use the count plugin to expose it in the GraphQL response.
The params go only to where({ params, context }). Its groups are appended after middleware and inspected as client paths, so references receive joined scopes and hidden records read as missing. Middleware receives operation: 'find' and entry: 'upcomingBookings'; the scope payload stays unchanged. Authorize the root as RootQueryType.upcomingBookings. The paths that where names are the author's and are not checked against field read rules or queryable.
@simtlix/simfinity-js-client 1.1.0 chooses the last list root for client.find(type). Applications using that method should wait for a client fix before registering a view over that type. Call a view with client.execute(document, variables).
See the named views guide for params, resolution and limits, and the registerView() reference for the registration contract.
Register custom mutations beyond the automatic CRUD operations:
simfinity.registerMutation(
'sendBookNotification',
'Send notification about a book',
BookNotificationInput, // Input type
NotificationResult, // Output type
async (args, session) => {
// Custom business logic
const book = await BookModel.findById(args.bookId);
// Send notification logic here
return { success: true, message: 'Notification sent' };
}
);
Include types in the schema without generating endpoints. See the detailed guide on addNoEndpointType() for when and how to use this pattern:
// This type can be used in relationships but won't have queries/mutations
simfinity.addNoEndpointType(AddressType);
Each root mutation runs in its own transaction. To run several generated mutations, of several types, in one database transaction, opt in when building the schema (available since 3.16.0):
const schema = simfinity.createSchema(null, null, null, {
transaction: { types: [SerieType, SeasonType] },
});
The schema gains transaction(operations: [TransactionOperation!]!): [TransactionResult!]. Each item of operations is a @oneOf object keyed by a generated mutation (addserie, updateseason, deleteserie, activate_season, ...) whose value is that mutation's input; a delete takes { id }, or { id, version } for a type with optimistic concurrency. Inside an id position, $K refers to the ID of the record the earlier operation K wrote:
mutation LaunchSerie($operations: [TransactionOperation!]!) {
transaction(operations: $operations) {
id
addserie { id name }
addseason { id number }
}
}
{
"operations": [
{ "addserie": { "name": "Northern Lights" } },
{ "addseason": { "number": 1, "year": 2024, "serie": { "id": "$0" } } }
]
}
Everything commits together or nothing is stored. Each operation gets the authorization checks of its root, runs the root's middleware and its normal lifecycle, and the whole list is repeated on a retry. An operation's error carries extensions.operationIndex and extensions.operation. The adapter must implement assertTransactionModels, which the MongoDB, SQL and PostgreSQL adapters do; on MongoDB every model must share one MongoClient. After-commit controller callbacks from all operations share the transaction owner's queue: only a confirmed attempt dispatches, after the whole list commits, and callback failures do not fail or retry its writes. See the mutations guide for references, limits and errors, and the MCP guide to publish it as one tool.
Use your existing Mongoose models:
const mongoose = require('mongoose');
const BookSchema = new mongoose.Schema({
title: String,
author: String,
publishedDate: Date
});
const BookModel = mongoose.model('Book', BookSchema);
// Use existing model
simfinity.connect(BookModel, BookType, 'book', 'books');
A supplied model may also use another connection, such as one from mongoose.createConnection(). Generated mutations and saveObject() use the model's connection. In the default referentialIntegrity: 'off' mode, custom mutations use the default Mongoose connection unless it is unused, never opened and without compiled models, also on its useDb() descendants, and every registered model shares one MongoDB client; with transactional reference integrity they use the first protected model's connection. See which connection the session uses.
Access data programmatically outside of GraphQL:
// Save an object programmatically
const newBook = await simfinity.saveObject('Book', {
title: 'New Book',
author: 'Author Name'
}, session);
// Since 3.16.0, update, delete and run a state action through the lifecycle
const updated = await simfinity.updateObject('Book', { id: String(newBook._id), title: 'Second Edition' }, session);
const deleted = await simfinity.deleteObject('Book', { id: String(newBook._id) }, session); // the record, or null
const processed = await simfinity.transitionObject('Order', 'process', { id: String(orderId) }, session);
// Get the Mongoose model for a type
const BookModel = simfinity.getModel(BookType);
const books = await BookModel.find({ author: 'Douglas Adams' });
// Get the GraphQL type definition by name
const UserType = simfinity.getType('User');
console.log(UserType.name); // 'User'
console.log(UserType.getFields()); // Access GraphQL fields
// Get the input type for a GraphQL type
const BookInput = simfinity.getInputType(BookType);
Since 3.16.0, updateObject(), deleteObject() and transitionObject() run the lifecycle of the generated update, delete and state action mutations, in an owned transaction or in the active session you pass. Like saveObject(), they skip GraphQL coercion, field authorization and the root record's global middleware, but they validate and copy their input first: declared fields only, no state, internal enum values, and record keys as strings (String(record._id)). They queue their matching after-commit controller callback, waiting for the outer owner when a Simfinity-owned session is supplied. They resolve to the updated record (the deleted one, or null, for deleteObject()) and throw INVALID_OPERATION_TARGET, UNKNOWN_ACTION, INVALID_INPUT, REQUIRED_VALUE or the usual NOT_VALID_ID, VERSION_CONFLICT and NESTED_OPERATIONS_EXCEEDED. See update, delete and transition from code.
For jobs, workers, webhooks and other server code, read server-side operations: it shows when to use a mutation with nested writes, a controller hook, in-process execution of the generated schema, a custom mutation with saveObject() or the native model with the session, and what each of them runs. Usage anti-patterns lists the approaches to avoid.
The optional application ESLint plugin provides fourteen error rules and opt-in modeling advice for specific Simfinity 3.8.0 contracts, including permission-map shapes, rule composition, required role/permission names and malformed authorization expressions. Install the separate development tool with npm install --save-dev 'eslint@^9.30.1' '@simtlix/[email protected]'. It has no automatic permission or storage fixes. It complements the application skills; it does not certify authorization, scopes or transaction safety.
Plugin 0.3.1 recognizes registerView() without adding rules or changing the preset contract. Plugin 0.3.0 adds valid-policy-expression to the recommended preset, following 0.2.0's checks for async authorization extractors and Envelop plugins passed to runtime middleware. These three contracts were revalidated against Simfinity 3.8.0 and 3.10.0. Upgrading can introduce diagnostics for malformed policies. See the linting guide for examples and analysis limits.
Plugin 0.5.0 covers the modeling catalogue (#116) with bounded checks for fields, engine storage, registrations, generated inputs and runtime-bound schema copies. It adds valid-model-fields, valid-model-storage and valid-model-schema to recommended-3.8, and extends collection-link and cloning checks. Accepted omissions and contextual choices appear only in the optional advice-3.8 preset. Upgrading can introduce new errors; see the coverage matrix and limits.
Simfinity can automatically expose every generated GraphQL operation as a Model Context Protocol (MCP) tool. For each connected type you get one MCP tool per generated query and mutation — exactly mirroring the GraphQL surface:
| GraphQL operation | MCP tool name |
|---|---|
| Single-entity query | book |
| List query | books |
| Aggregate query | books_aggregate |
| Add mutation | addbook |
| Update mutation | updatebook |
| Delete mutation | deletebook |
| State-machine action | publish_book |
| Custom mutation | the registered name |
| Named view | the view name |
| Virtual field with a required argument | book_readers (<byIdQuery>_<field>) |
Each generated tool follows MCP best practices so an agent can use it without extra context:
description: reuses the GraphQL type/field descriptions and adds an actionable summary. List and aggregate tools spell out the available filter operators (EQ, NE, LT, LTE, GT, GTE, IN, NIN, IIN, CONTAINS_ALL, BTW, LIKE, ILIKE, STARTS_WITH, ISTARTS_WITH, ENDS_WITH, IENDS_WITH, IEQ, IS_EMPTY, IS_NOT_EMPTY) and that IS_EMPTY and IS_NOT_EMPTY take no value and apply to the field itself without a path, an embedded object or list, or a reference or collection where the database adapter supports it, AND/OR groups, pagination and sorting; aggregate tools additionally document the SUM/COUNT/AVG/MIN/MAX operations and the aggregation argument.title: a human-readable label (e.g. List Book, Create Book, Aggregate Book). State-machine actions, custom mutations, custom queries and named views are titled by their field name.inputSchema: JSON Schema derived from the GraphQL arguments (scalars, enums, input objects, lists, non-null wrappers, and recursive filter types such as QLFilterGroup via $defs/$ref). Nullable arguments, input fields and list items accept explicit null; non-null positions reject it. Nullable references use anyOf with a null alternative. GraphQL type and field descriptions are propagated, with curated descriptions for synthetic types. Date/DateTime/Time scalars use the matching JSON Schema format as a hint for the value to send (input only; see outputSchema). Defaulted arguments are optional; representable defaults appear in the schema and generated GraphQL variables, with enum member names rather than internal values. Omitted opaque scalar defaults that have no GraphQL literal representation use the field's original default. Validated scalars, including chains built on other validated scalars, use the JSON type of their root scalar; a custom scalar's description is published unless the field or argument has its own. The arguments object is closed (additionalProperties: false): hosts that validate tool input reject undeclared keys, and callTool rejects an undeclared key the caller sent with MCP_UNKNOWN_ARGUMENT (see Tool results and errors). Nested input objects are not closed in the schema; GraphQL already rejects their unknown fields.On execution, each tool returns both a serialized JSON text content block and a machine-readable structuredContent (the GraphQL data, plus the totalCount or truncated keys MCP adds next to the root field) that conforms to the outputSchema. When the caller requests pagination: { count: true } on a list tool, the total record count is returned as totalCount next to the results, so the model sees it, and also in the tool result _meta.count for client code (aggregate tools ignore the flag — their resolver never computes a total).
The MCP transports require the optional
@modelcontextprotocol/sdkdependency (^1.31.0). Install it withnpm install @modelcontextprotocol/sdk@^1.31.0.generateMCPToolsworks without it. SDK load problems raise distinct error codes:MCP_SDK_NOT_INSTALLED(not installed),MCP_SDK_INCOMPATIBLE(installed but too old to provide the requested transport, or missing an expected export such asServer— install a supported version withnpm install @modelcontextprotocol/sdk@^1.31.0),MCP_SDK_LOAD_FAILED(any other import failure). The factories load the SDK, and the stdio or Streamable HTTP transport, before they validate options or runschemaPlugins, so an SDK error comes before any option error,transportOptionsincluded, and leaves the schema untouched.
npm install @modelcontextprotocol/sdk@^1.31.0
generateMCPTools(schema, options) returns the tool list, a callTool executor and a getOperation inspector. It does not require the SDK and is handy for inspection or custom wiring:
import * as simfinity from '@simtlix/simfinity-js';
const schema = simfinity.createSchema();
const { tools, callTool, getOperation } = simfinity.generateMCPTools(schema);
// tools: [{ name, title, description, inputSchema, outputSchema, annotations, kind }, ...]
const result = await callTool('addbook', { input: { title: 'Dune' } });
// result: { content: [{ type: 'text', text: '...JSON...' }],
// structuredContent: { addbook: { ... } }, isError: false }
// getOperation returns the prebuilt GraphQL document a tool executes
console.log(getOperation('books'));
// query booksOperation($id: QLFilter, $pagination: QLPagination, ...) {
// books(id: $id, pagination: $pagination, ...) { id title ... }
// }
Both callTool and getOperation throw a SimfinityError with code MCP_TOOL_NOT_FOUND for unknown tool names. Generated tools share one inputSchema.$defs entry object per input or enum type, so treat the definitions as read-only and copy a tool (for example with structuredClone) before changing it.
Use stdio for a standalone executable consumed by clients like Cursor or Claude Desktop:
import * as simfinity from '@simtlix/simfinity-js';
const schema = simfinity.createSchema();
await simfinity.startStdioMCPServer(schema, {
serverName: 'my-api-mcp',
context: { user: { id: 'system' } },
});
Mount an MCP endpoint (Streamable HTTP) next to your existing GraphQL server:
import express from 'express';
import * as simfinity from '@simtlix/simfinity-js';
const schema = simfinity.createSchema();
const app = express();
app.use(express.json());
const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
// In HTTP mode the context factory receives the Express `req`,
// so you can authenticate per request (e.g. from the Authorization header).
context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
});
app.all('/mcp', mcpHandler);
Mount the handler for every method (app.all). It is stateless: it serves POST requests, creating a fresh MCP server and transport for each, and answers any other method, such as the GET an MCP client sends to open a server stream, with HTTP 405, Allow: POST and a JSON-RPC error, without calling context or onError. MCP clients treat a 405 on GET as "no server stream". With app.post, Express answers that GET with 404, which SDK clients report through onerror.
For deployments reachable from a browser (especially localhost), enable the SDK's DNS-rebinding protection via transportOptions, and observe failures with onError:
const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
// Spread into the SDK's StreamableHTTPServerTransport options
transportOptions: {
enableDnsRebindingProtection: true,
allowedHosts: ['127.0.0.1:3000', 'localhost:3000'],
allowedOrigins: ['http://localhost:3000'],
},
onError: (err, req, res) => console.error('MCP request failed', err),
});
The handler catches all errors itself: onError is for logging/metrics, and a JSON-RPC internal error (HTTP 500) is sent automatically when response headers were not already written.
| Option | Default | Description |
|---|---|---|
execution | { mode: 'in-process' } | in-process runs against the in-memory schema. remote issues HTTP GraphQL requests: { mode: 'remote', endpoint, headers, timeoutMs, retry: { attempts, backoffMs, retryAfter, maxRetryAfterMs, jitter } }. Remote endpoint (for credentials only), headers and timeoutMs are validated at setup (MCP_INVALID_EXECUTION_CONFIG) and read again on every call. See Remote execution. |
context | {} | GraphQL context value, or a factory. For in-process/stdio it is called as (extra) => context; for the HTTP handler it is called as (req, extra) => context so you can authenticate per request. Passed through to execution so scope functions and auth rules work (they read context.user). |
include / exclude | — | String or array: raw GraphQL field names, prefixed tool names, or the categories 'query' / 'mutation'. exclude wins over include. A virtual field tool matches its own names (book_readers), not its by-ID query's. |
includeTypes / excludeTypes | — | String or array of entity (return) type names — one entry covers every tool for that entity ('Book' matches book, books, books_aggregate, addbook, ...). excludeTypes wins. |
selectionDepth | 1 | Nesting depth for the auto-generated output selection set (and the mirrored outputSchema). Each nested collection it reaches is read with one query per returned row, or, with an adapter that batches collection reads, one per nested collection field and batch of rows (batched collection reads). |
In TypeScript, the options parameter is typed GenerateMCPToolsInit (generateMCPTools), MCPServerInit (createMCPServer, startStdioMCPServer) or HTTPMCPHandlerInit (createHTTPMCPHandler). Each accepts every value of its option interface plus the wider forms the runtime takes. limits may be an MCPLimitsInit, whose caps are numbers, numeric strings, bigints, null or false. A remote execution may be an MCPRemoteExecutionInit, whose headers is a plain object, Headers, Map or [name, value] pairs and whose timeoutMs is a number, numeric string, bigint, null or false. GenerateMCPToolsOptions, MCPServerOptions, HTTPMCPHandlerOptions, MCPLimits and MCPRemoteExecutionOptions keep the 3.5.6 shapes of their existing members and gain only optional ones (nestedCollections, textFormat and the retry opt-ins), so code that reads options back compiles unchanged.
Selection sets select every scalar/enum field without required arguments, id included (managed version too; keep it in explicit selections), except that tools whose root field returns a list select only the virtual fields that set mcp: 'select', since each listed record runs their resolvers. An object type with no such field selects __typename, and interface/union return types select __typename, so the generated documents are always statically valid — and the output schemas mirror the same fallbacks.
Trim and namespace the published surface with include/exclude, includeTypes/excludeTypes and toolNamePrefix:
const { tools, callTool, getOperation } = simfinity.generateMCPTools(schema, {
exclude: 'mutation', // read-only server
excludeTypes: 'AuditLog', // hide every tool whose entity type is AuditLog
toolNamePrefix: 'catalog_', // books -> catalog_books
});
// callTool/getOperation use the published (prefixed) name;
// the underlying GraphQL document still uses the raw field name.
console.log(getOperation('catalog_books'));
// query booksOperation(...) { books(...) { ... } }
include/exclude entries match the raw GraphQL field name, the prefixed tool name, or the categories 'query' / 'mutation'. exclude always wins over include.includeTypes/excludeTypes match the entity (return) type. Aggregate queries resolve their entity through the sibling list field (books_aggregate → books), so excludeTypes: 'Book' also hides the aggregate tool. With an includeTypes allowlist, tools without an object return type are dropped too.console.warn — rename one of the GraphQL fields or use include/exclude. Virtual field tools come after every query and mutation tool, so they are the ones skipped.toolOverrides tunes individual tools without touching the schema. Keys accept either the published tool name or the unprefixed field name:
const { tools } = simfinity.generateMCPTools(schema, {
toolOverrides: {
books: {
title: 'Search books',
description: 'Search the book catalog. Filter by title, author or publication year.',
selectionDepth: 2, // deeper selection set for this tool only
},
publish_book: {
annotations: { idempotentHint: true }, // merged over the generated annotations
},
book: {
selection: 'id title author { name }', // explicit selection set
},
},
});
To keep nested collections out of list results, which are read with one query per returned row (or, with batchedCollections: true, one statement per nested collection field and 100 rows on PostgreSQL, and one command per nested collection field and collectionBatchSize rows on MongoDB), set nestedCollections: 'none' globally or per tool:
const { tools } = simfinity.generateMCPTools(schema, {
nestedCollections: 'none', // no nested collections in any tool...
toolOverrides: { book: { nestedCollections: 'all' } }, // ...except this one
});
selection accepts 'id title' or '{ id title }' and replaces the auto-generated selection set, ignoring nestedCollections. When it is set, the outputSchema is omitted for that tool — it can no longer be guaranteed to match the returned data, and publishing a wrong schema would make validating clients reject valid results.
Override annotations only when the hint holds for the stored data. Marking a state-machine action such as publish_book idempotent is safe because a repeat fails the action's from-state check; it is not safe for an action that keeps the record in the same state while its input inserts collection items (added), since every repeat inserts them again.
limits protects agents (and your database) from oversized requests and responses. Violations come back as regular tool results, not exceptions:
const { callTool } = simfinity.generateMCPTools(schema, {
limits: {
maxPageSize: 100, // MCP_PAGE_SIZE_EXCEEDED above this
defaultPagination: { page: 1, size: 20 }, // injected when the caller sends none
maxResultBytes: 262144, // MCP_RESULT_TOO_LARGE above this (mutations: ids + notice)
},
});
maxPageSize: rejects calls whose pagination.size exceeds the cap.defaultPagination: injected only for tools that have a pagination argument, and only when the caller did not send one. At setup it is checked against the pagination argument type of every published tool: for QLPagination, page and size are required positive integers, count must be a boolean and no other key is allowed; a custom pagination type is checked against its own GraphQL type. size must not exceed maxPageSize. Core's own page-size cap (configureQueryLimits, default 1000) is not checked.maxResultBytes: caps the UTF-8 byte length of the text content block (pretty-printed by default, compact JSON with textFormat: 'compact'): data on success (including a counted list's totalCount), or { errors, data? } on failure. Oversized query results, errors and partial data are replaced by MCP_RESULT_TOO_LARGE; for a counted list it carries the total in extensions.totalCount. An oversized mutation result is not an error when the mutation succeeded, because the write already ran: the tool returns isError: false, _meta.truncated: true and the root id(s) next to a truncated notice ({ id, version } for protected results) (see Tool results and errors). Limit diagnostics (MCP_RESULT_TOO_LARGE, MCP_PAGE_SIZE_EXCEEDED, MCP_UNKNOWN_ARGUMENT) and that notice are exempt from the cap, so even a tiny cap can return an actionable result; the root id, or the id list, is returned next to the notice only when it fits within the cap. The cap excludes MCP envelope overhead, the duplicate structuredContent, and middleware-created results; it runs after execution and does not limit database work or remote download size.selectionDepth: 1, each one is read with one query per returned row, or one per batch of rows with an adapter that batches collection reads, up to 100 items (or configureQueryLimits maxPageSize when lower). Use nestedCollections: 'none' for list-heavy agents. Nested collection writes through add and update tools are not bounded either; cap them with configureMutationLimits({ maxNestedOperations }), and a transaction tool's list with .limits is validated when generateMCPTools, createMCPServer, startStdioMCPServer or createHTTPMCPHandler sets up, before schemaPlugins run, and invalid settings throw MCP_INVALID_LIMITS:
limits, defaultPagination or cap (undefined, null, false, 0, '', as cond && 100 produces) leaves it unset; a truthy limits or defaultPagination must be an object.process.env, 'Infinity' included) and bigints are converted. NaN applies no cap and logs one warning per setup. Numbers below 1 (-1, 0.5), true, blank strings, strings that are not numeric or that convert to a number below 1 ('0', '0.5'), objects, arrays and functions throw.defaultPagination that a tool's pagination type rejects throws with the tool name and the GraphQL error, e.g. limits.defaultPagination is not a valid QLPagination for the pagination argument of tool "books": Field "page" of required type "Int!" was not provided.The object is read live: callTool reads the limits object you passed on every call, and createHTTPMCPHandler reads options.limits again for every request, so later changes apply. A cap changed after setup to an invalid value makes calls reject with MCP_INVALID_LIMITS; a defaultPagination set later is injected without the setup type check.
toolMiddleware wraps every tool execution with koa-style (call, next) functions — for logging, metrics, argument rewriting or policy checks. call is { name, args, extra, kind, operation }:
const { callTool } = simfinity.generateMCPTools(schema, {
toolMiddleware: [
async (call, next) => {
const started = Date.now();
const result = await next();
console.log(`[mcp] ${call.name} (${call.kind}) took ${Date.now() - started}ms`);
return result;
},
async (call, next) => {
if (call.kind === 'mutation' && process.env.MCP_READ_ONLY === 'true') {
// Short-circuit: return a result without calling next()
return {
content: [{ type: 'text', text: 'Mutations are disabled on this server.' }],
isError: true,
};
}
return next();
},
],
});
Middleware can mutate call.args before calling next(), short-circuit by returning a result, or throw. Calling next() twice rejects with MCP_MIDDLEWARE_ERROR.
next() promise rejects, so outer try { return await next(); } catch { ... }, .catch() or .finally() boundaries see the error; a synchronous try { return next(); } catch {} without await catches nothing. A middleware may start next() and await other work before awaiting it. A rejected next() promise that no middleware awaits or returns is dropped rather than left as an unhandled rejection.call.args when the chain reaches execution are rejected with MCP_UNKNOWN_ARGUMENT, and the tool does not run. The caller's keys are recorded before middleware runs, so a middleware that reads a custom key (say reason) must delete call.args.reason before next(). That only helps with hosts that do not validate tool input: hosts that validate against the closed inputSchema reject undeclared keys before they reach the server. Keys that middleware adds, such as an owner filter injected into every tool, pass through as before, even to tools that do not declare them.Pass a function or an array of functions. Every function runs, whatever its declared parameter count. A non-array value or a non-function entry (including null from cond && mw, and an empty slot in a sparse array such as [a, , b]) throws MCP_INVALID_MIDDLEWARE at setup. The stack is captured at setup.
execution.mode: 'remote' turns the MCP server into a thin proxy that POSTs each operation to an existing GraphQL endpoint. Harden it with timeoutMs and retry:
const { callTool } = simfinity.generateMCPTools(schema, {
execution: {
mode: 'remote',
endpoint: 'https://api.example.com/graphql',
headers: { Authorization: `Bearer ${token}` },
timeoutMs: 10000, // abort requests slower than 10s
retry: { attempts: 2, backoffMs: 250 }, // up to 2 extra attempts, linear backoff (250, 500 ms)
},
});
endpoint is passed to fetch as given (a string, URL or Request) and must not include a username or password; send credentials in headers. fetch rejects such a URL on every call, so it throws MCP_INVALID_EXECUTION_CONFIG at setup. Relative, unparsable and non-http(s) endpoints are accepted at setup, for an application that replaces globalThis.fetch; when fetch rejects one with an error that quotes the URL, the call fails with MCP_REMOTE_REQUEST_FAILED and a message that the endpoint must be an absolute http(s) URL; other fetch errors keep their message. No message quotes the URL. An endpoint changed after setup to one with credentials fails calls with MCP_REMOTE_REQUEST_FAILED and the setup message, before any request is sent.headers accepts a plain object (its own enumerable string keys), a Headers instance, a Map or an array of [name, value] pairs; any falsy value sends no extra headers. Names are case-insensitive: a Content-Type in any casing replaces the application/json default and is sent once. Headers are read on every call, so mutating or replacing them (e.g. a rotated token) applies to later calls; they are configuration, not a per-request factory, and incoming MCP credentials are not forwarded. A string, number, true, promise, function or one-shot iterator (map.entries(), a generator) throws MCP_INVALID_EXECUTION_CONFIG at setup, without reading any header value. A get headers() or get timeoutMs() getter defined on the execution object itself is not run at setup, only on every call, where its value is checked. A getter inherited from a class, or a Proxy execution object, is read at setup; if that read throws (say, until a token loads), the property is read and checked on every call only. An invalid header name or value fails the call with MCP_REMOTE_REQUEST_FAILED before any request is sent, naming the header but never its value. fetch receives a plain object with lowercase names. In TypeScript the wider forms are typed MCPRemoteExecutionInit/MCPRemoteHeadersInit.timeoutMs aborts slow attempts, including reading the response body (AbortSignal.timeout); the timeout signal is combined with the MCP client's cancellation signal. It takes positive milliseconds: numeric strings and bigints are converted, fractions round up and values above 2147483647 (the Node.js timer limit) are capped. Unset, , , , , and disable it; , negative, and non-numeric values throw at setup. It is read again on every call.schemaPlugins is not applied in remote mode — the remote GraphQL server enforces its own plugins.
Successful calls return { content: [textJSON], structuredContent, isError: false }, where structuredContent is the GraphQL data plus any key MCP adds next to the root field (totalCount, or truncated for an oversized mutation; __totalCount/__truncated when the root field has that name), and the text is the same JSON. Failures and edge cases behave as follows:
pagination: { count: true } on a list tool (sent by the caller or injected by limits.defaultPagination), the total is returned as totalCount next to the results, in structuredContent and in the text, and as _meta.count on the tool result. In-process calls report zero too. There, Simfinity's generated list resolver hands the count to a per-call sink in the GraphQL root value, so the tool receives your context object itself. A list field without core's extensions.simfinityQuery.countSink marker, such as a custom list query whose resolver writes context.count, gets a per-call object whose prototype is your context on counted calls. The marker, not the resolver that runs, decides: if you replace or wrap a generated list field's resolver, forward parent or info to the generated one, report the total through the function under parent[Symbol.for('simfinity.countSink')], or set the field's extensions.simfinityQuery.countSink to false before creating the tools; otherwise the count lands on your shared context and the call returns no total. In remote mode the count comes from the response extensions.count; Simfinity's count plugins omit a count of zero, so a remote call that matches nothing returns neither totalCount nor _meta.count. Aggregate tools never deliver a count.maxResultBytes, the write has already run. A successful one returns isError: false, _meta: { truncated: true } and structuredContent such as { addbook: { id: '9' }, truncated: { applied: true, message, resultBytes, maxResultBytes } } (the root id, or the ids of a returned list, when they fit within the cap; retaining { id, version } for protected results; the notice reports the cap rounded down to whole bytes). The message tells the agent whether calling the tool again would repeat the change, following the tool's idempotentHint, and never sends it to read back a deleted record. With GraphQL errors the result stays isError: true with MCP_RESULT_TOO_LARGE, extensions.applied (true when the root field returned a value, otherwise 'unknown', never false), extensions.firstError (the first error message, cut to 300 characters) and the ids in data when present. For the transaction tool, the notice preserves the all-or-none guarantee but keeps the outcome unknown when errors leave no root value, including errors after a successful commit during session cleanup or result serialization. Check the current state before retrying because committed changes could be repeated. Oversized query results keep the error, whose message also reports the cap rounded down to whole bytes.The MCP tools execute the same resolvers as GraphQL, so the GraphQL context flows through unchanged: pass credentials via options.context (a value or a factory). Query scope functions read the context on every execution and need no extra wiring.
Field-level rules from createAuthPlugin are different. The auth plugin is an Envelop plugin that wraps resolvers from its onSchemaChange hook — a hook that only fires when an Envelop/Yoga server boots. A standalone MCP server executes operations with bare graphql(), so without help the hook never fires and the field-level rules are silently skipped. Pass the plugin via schemaPlugins to get auth parity: every plugin's onSchemaChange is invoked once before serving (in-process execution only):
import * as simfinity from '@simtlix/simfinity-js';
const { createAuthPlugin, requireAuth, requireRole } = simfinity.auth;
// Permissions are keyed by the schema's type names. Simfinity names its
// roots `RootQueryType` and `Mutation`.
const permissions = {
RootQueryType: { books: requireAuth() },
Mutation: { deletebook: requireRole('admin') },
};
const schema = simfinity.createSchema();
await simfinity.startStdioMCPServer(schema, {
// requireRole reads `user.role` by default (see rolePath to customize).
context: { user: { id: 'agent', role: 'admin' } },
schemaPlugins: [createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' })],
});
MCP calls only onSchemaChange, so pass the factory result (createAuthPlugin(...)), not the factory. These setup rules apply:
then method or getter), a non-function onSchemaChange, a non-array schemaPlugins value or an object with no plugin hooks throws MCP_INVALID_SCHEMA_PLUGIN before any hook runs.onSchemaChange on a plugin without a function onSchemaChange: an own or inherited method or getter whose name starts with onSchema in any letter case, or is at most two edits from onSchemaChange ignoring case, such as onSchemaChanged, onSchemChange or onShemaChange. The message suggests onSchemaChange. Helpers such as onSchemaReady next to a working onSchemaChange are accepted. Validation reads onSchemaChange directly and inspects every other property, then included, through its descriptor, so it calls no other getter.on*, instrumentation, requestDidStart, serverWillStart) get a one-time console warning naming the ignored hooks; plugins added via onPluginInit/addPlugin are not installed.replaceSchema with a different schema throws MCP_UNSUPPORTED_SCHEMA_REPLACEMENT.onSchemaChange is awaited: createMCPServer, startStdioMCPServer and createHTTPMCPHandler resolve after installation and reject if it fails. generateMCPTools stays synchronous; its calls wait for installation and reject with the installer's error if it failed. Envelop and Yoga do not await onSchemaChange, so plugins shared with Yoga should still install synchronously.Mounting the MCP handler next to a running Envelop/Yoga server that shares the same schema object also works: Yoga fires onSchemaChange at startup and createAuthPlugin wraps the resolvers in place, so the MCP tools execute the already-wrapped schema.
For per-request HTTP auth, use a function context: the HTTP handler invokes it as (req, extra) => context, so you can read the Authorization header from the Express req and map it to context.user exactly like your GraphQL server does.
Simfinity.js now supports powerful GraphQL aggregation queries with GROUP BY functionality, allowing you to perform aggregate operations (SUM, COUNT, AVG, MIN, MAX) on your data.
For each entity type registered with connect(), an additional aggregation endpoint is automatically generated with the format {entityname}_aggregate.
SUM: Sum of numeric valuesCOUNT: Count of recordsAVG: Average of numeric valuesMIN: Minimum valueMAX: Maximum valueinput QLTypeAggregationFact {
operation: QLAggregationOperation!
factName: String!
path: String!
}
input QLTypeAggregationExpression {
groupId: String!
facts: [QLTypeAggregationFact!]!
}
type QLTypeAggregationResult {
groupId: JSON
facts: JSON
}
If your schema already has a scalar named JSON, such as GraphQLJSON from graphql-scalars or graphql-type-json, groupId and facts use that scalar, and its serialize formats the results. Otherwise Simfinity adds its own JSON scalar. The reused scalar must serialize any JSON value: groupId may be a string, a number or null, not only an object, so a JSONObject-style scalar would turn aggregation results into field errors. Any other application type named JSON still conflicts.
query {
series_aggregate(
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
]
}
) {
groupId
facts
}
}
query {
series_aggregate(
aggregation: {
groupId: "country.name"
facts: [
{ operation: COUNT, factName: "count", path: "id" }
{ operation: AVG, factName: "avgRating", path: "rating" }
]
}
) {
groupId
facts
}
}
query {
series_aggregate(
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
{ operation: SUM, factName: "totalEpisodes", path: "episodeCount" }
{ operation: AVG, factName: "avgRating", path: "rating" }
{ operation: MIN, factName: "minRating", path: "rating" }
{ operation: MAX, factName: "maxRating", path: "rating" }
]
}
) {
groupId
facts
}
}
query {
series_aggregate(
rating: { operator: GTE, value: 8.0 }
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "highRated", path: "id" }
]
}
) {
groupId
facts
}
}
query {
series_aggregate(
sort: {
terms: [
{ field: "total", order: "DESC" }, # Sort by count first
{ field: "groupId", order: "ASC" } # Then by name
]
}
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
{ operation: AVG, factName: "avgRating", path: "rating" }
]
}
) {
groupId
facts
}
}
query {
series_aggregate(
sort: {
terms: [{ field: "total", order: "DESC" }]
}
pagination: {
page: 1
size: 5
}
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
]
}
) {
groupId
facts
}
}
The groupId and path parameters support:
Direct Fields: Simple field names from the entity
"category", "rating", "id"Related Entity Fields: Dot notation for fields in related entities
"country.name", "studio.foundedYear"Nested Related Entities: Multiple levels of relationships
"country.region.name"field parameter to:
"groupId" to sort by the grouping field"avgRating", "total") to sort by that calculated metricorder parameter (ASC/DESC) determines the sort direction for each fieldpage and size must be positive safe integers, and size must fit the configured maximum (1000 by default). Invalid input produces INVALID_PAGINATION with status 400.count parameter is ignored for aggregation queriesAggregation queries are translated to efficient MongoDB aggregation pipelines:
Results are returned in a consistent format:
{
"groupId": <value>,
"facts": {
"factName1": <calculated_value>,
"factName2": <calculated_value>
}
}
For complete documentation with more examples, see AGGREGATION_EXAMPLE.md and AGGREGATION_CHANGES_SUMMARY.md.
Here's a complete bookstore example with relationships, validations, and state machines:
const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const mongoose = require('mongoose');
const {
GraphQLObjectType,
GraphQLString,
GraphQLNonNull,
GraphQLID,
GraphQLList,
GraphQLInt,
GraphQLEnumType
} = require('graphql');
const simfinity = require('@simtlix/simfinity-js');
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/bookstore', {
useNewUrlParser: true,
useUnifiedTopology: true,
});
// Define Types
const AuthorType = new GraphQLObjectType({
name: 'Author',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
email: { type: GraphQLString },
books: {
type: new GraphQLList(BookType),
extensions: {
relation: {
connectionField: 'author',
displayField: 'title'
},
},
},
}),
});
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: {
type: new GraphQLNonNull(GraphQLString),
extensions: {
validations: {
save: [{
validate: async (typeName, fieldName, value, session) => {
if (!value || value.length < 2) {
throw new simfinity.SimfinityError('Title must be at least 2 characters', 'VALIDATION_ERROR', 400);
}
}
}]
}
}
},
pages: { type: GraphQLInt },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
},
}),
});
// Define Controllers
const bookController = {
onSaving: async (doc, args, session) => {
console.log(`Creating book: ${doc.title}`);
},
onSaved: async (doc, args, session) => {
console.log(`Book saved: ${doc.title}`);
}
};
// Connect Types
simfinity.connect(null, AuthorType, 'author', 'authors');
simfinity.connect(null, BookType, 'book', 'books', bookController);
// Create Schema
const schema = simfinity.createSchema();
// Setup Express Server
const app = express();
app.use('/graphql', graphqlHTTP({
schema,
graphiql: true,
customFormatErrorFn: simfinity.buildErrorFormatter((err) => {
console.error(err.getCode(), err.message);
})
}));
app.listen(4000, () => {
console.log('Bookstore API running on http://localhost:4000/graphql');
});
Apache-2.0 License - see the LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request.
Built with ❤️ by Simtlix
Here are some practical GraphQL query examples from the series-sample project, showcasing how to use simfinity.js effectively:
Find all series that have directors from the United States:
query {
series(director: {
terms: [
{
path: "country",
operator: EQ,
value: "United States"
}
]
}) {
id
name
categories
director {
name
country
}
}
}
Find series that contain an episode with the name "Pilot":
query {
series(
seasons: {
terms: [
{
path: "episodes.name",
operator: EQ,
value: "Pilot"
}
]
}
) {
id
name
seasons {
number
episodes {
number
name
date
}
}
}
}
Find series that feature "Bryan Cranston":
query {
assignedStarsAndSeries(star: {
terms: [
{
path: "name",
operator: EQ,
value: "Bryan Cranston"
}
]
}) {
id
star {
name
}
serie {
id
name
categories
director {
name
country
}
}
}
}
Find all seasons that belong to series directed by someone from the United States:
query {
seasons(serie: {
terms: [
{
path: "director.country",
operator: EQ,
value: "United States"
}
]
}) {
id
number
year
state
serie {
name
categories
director {
name
country
}
}
episodes {
number
name
date
}
}
}
The collection condition below is one ANY frame, so several matching seasons do not repeat the series. Conditions in that same term set need one season; append a separate group to permit another season. For absence or universal tests, use an explicit NONE/ALL group or a relation-filter quantifier; see collection frames.
Find series named "Breaking Bad" that have at least one season with number 1:
query {
series(
name: {
operator: EQ,
value: "Breaking Bad"
}
seasons: {
terms: [
{
path: "number",
operator: EQ,
value: 1
}
]
}
) {
id
name
director {
name
country
}
seasons {
number
episodes {
name
}
}
}
}
Get complete information for a specific series:
query {
series(name: {
operator: EQ,
value: "Breaking Bad"
}) {
id
name
categories
director {
name
country
}
seasons {
number
year
state
episodes {
number
name
date
}
}
}
}
Find all episodes from Season 1 of Breaking Bad:
query {
episodes(season: {
terms: [
{
path: "number",
operator: EQ,
value: 1
},
{
path: "serie.name",
operator: EQ,
value: "Breaking Bad"
}
]
}) {
id
number
name
date
season {
number
serie {
name
}
}
}
}
Find all crime series:
query {
series(categories: {
operator: EQ,
value: "Crime"
}) {
id
name
categories
director {
name
country
}
}
}
Find episodes containing "Fire" in the name:
query {
episodes(name: {
operator: LIKE,
value: "Fire"
}) {
id
number
name
date
season {
number
serie {
name
}
}
}
}
Simfinity.js supports built-in pagination with optional total count:
Configure the process-wide maximum once during application startup:
import * as simfinity from '@simtlix/simfinity-js';
simfinity.configureQueryLimits({ maxPageSize: 500 });
The default maximum is 1000. Explicit page and size must be positive safe integers, size must not exceed the maximum, and the computed skip must remain a safe integer. Invalid pagination throws INVALID_PAGINATION (400); invalid configuration throws INVALID_QUERY_LIMITS (400). With no pagination, a list returns at most Math.min(100, maxPageSize) records. Any positive safe-integer maximum is allowed, including values below 100. Call configureQueryLimits() to restore the default maximum. Its maxReferenceFrames option, 10 by default, caps the distinct scoped joins and frames that one set of IS_EMPTY and IS_NOT_EMPTY conditions reads through references and collections. The client's arguments of each list, aggregate, collection-field or named view call are one set. A view's where is a separate set held to the same limit. More fail with FILTER_DEPTH_EXCEEDED (400); middleware and scope conditions are not counted, and an option that a call omits takes its default again. This deliberately tightens previously unbounded explicit page sizes; applications needing larger pages can configure an appropriate maximum. Unpaginated aggregate queries retain their existing behavior.
query {
series(
categories: {
operator: EQ,
value: "Crime"
}
pagination: {
page: 1,
size: 2,
count: true
}
) {
id
name
categories
director {
name
country
}
}
}
true, returns total count of matching recordsWhen count: true is specified, the total count is available in the response extensions. The list resolver places it on the GraphQL context (a context object is required; without one the rows are returned and the count query is skipped). You need to configure a plugin to expose it. Simfinity.js provides utility plugins for both Apollo Server and Envelop:
const simfinity = require('@simtlix/simfinity-js');
// For Envelop
const getEnveloped = envelop({
plugins: [
useSchema(schema),
simfinity.plugins.envelopCountPlugin(),
],
});
// For Apollo Server
const server = new ApolloServer({
schema,
plugins: [
simfinity.plugins.apolloCountPlugin(),
],
});
See the Plugins for Count in Extensions section for complete examples.
{
"data": {
"series": [
{
"id": "1",
"name": "Breaking Bad",
"categories": ["Crime", "Drama"],
"director": {
"name": "Vince Gilligan",
"country": "United States"
}
},
{
"id": "2",
"name": "Better Call Saul",
"categories": ["Crime", "Drama"],
"director": {
"name": "Vince Gilligan",
"country": "United States"
}
}
]
},
"extensions": {
"count": 15
}
}
List sort fields must resolve to declared scalar or enum paths, and every term requires ASC or DESC. Root id and related author.id sort by their stored _id paths. Embedded paths remain dotted, an embedded .id follows the embedded id rule, and repeated sort terms through one relationship reuse the same lookup. Supply at least one term; invalid sort input is rejected before executing the list query.
Simfinity.js supports sorting with multiple fields and sort orders:
query {
series(
categories: { operator: EQ, value: "Crime" }
pagination: { page: 1, size: 5, count: true }
sort: {
terms: [
{
field: "name",
order: DESC
}
]
}
) {
id
name
categories
director {
name
country
}
}
}
ASC (ascending) or DESC (descending)You can sort by fields from related/nested objects using dot notation:
query {
series(
categories: { operator: EQ, value: "Drama" }
pagination: { page: 1, size: 5, count: true }
sort: {
terms: [
{
field: "director.name",
order: DESC
}
]
}
) {
id
name
categories
director {
name
country
}
}
}
You can sort by multiple fields with different orders:
query {
series(
sort: {
terms: [
{ field: "director.country", order: ASC },
{ field: "name", order: DESC }
]
}
) {
id
name
director {
name
country
}
}
}
The example above demonstrates combining filtering, pagination, and sorting in a single query - a common pattern for data tables and lists with full functionality.
Find series with seasons released between 2010-2015:
query {
seasons(year: {
operator: BETWEEN,
value: [2010, 2015]
}) {
id
number
year
serie {
name
director {
name
country
}
}
}
}
Simfinity.js provides built-in state machine support for managing entity lifecycles. Here's an example of how a state machine is implemented in the Season entity from the series-sample project.
State machines require GraphQL Enum Types to define states and proper state references:
Step 1: Define the GraphQL Enum Type
const { GraphQLEnumType } = require('graphql');
const seasonState = new GraphQLEnumType({
name: 'seasonState',
values: {
SCHEDULED: { value: 'SCHEDULED' },
ACTIVE: { value: 'ACTIVE' },
FINISHED: { value: 'FINISHED' }
}
});
Step 2: Use Enum in GraphQL Object Type
const seasonType = new GraphQLObjectType({
name: 'season',
fields: () => ({
id: { type: GraphQLID },
number: { type: GraphQLInt },
year: { type: GraphQLInt },
state: { type: seasonState }, // ← Use the enum type
// ... other fields
})
});
Step 3: Define State Machine with Enum Values
const stateMachine = {
initialState: seasonState.getValue('SCHEDULED'),
actions: {
activate: {
from: seasonState.getValue('SCHEDULED'),
to: seasonState.getValue('ACTIVE'),
action: async (params) => {
console.log('Season activated:', JSON.stringify(params));
}
},
finalize: {
from: seasonState.getValue('ACTIVE'),
to: seasonState.getValue('FINISHED'),
action: async (params) => {
console.log('Season finalized:', JSON.stringify(params));
}
}
}
};
// Connect type with state machine
simfinity.connect(null, seasonType, 'season', 'seasons', null, null, stateMachine);
The Season entity has three states:
Available transitions:
activate: SCHEDULED → ACTIVEfinalize: ACTIVE → FINISHEDSimfinity.js automatically generates state transition mutations:
# Activate a scheduled season
mutation {
activateseason(id: "season_id_here") {
id
number
year
state
serie {
name
}
}
}
# Finalize an active season
mutation {
finalizeseason(id: "season_id_here") {
id
number
year
state
serie {
name
}
}
}
Validation:
Custom Actions:
Query by State:
query {
seasons(state: {
operator: EQ,
value: ACTIVE
}) {
id
number
year
state
serie {
name
}
}
}
enumType.getValue('VALUE') for state machine configurationGraphQLEnumTypestate: { type: seasonState }seasonState.getValue('STATE_NAME')# 1. Create season (automatically SCHEDULED)
mutation {
addseason(input: {
number: 6
year: 2024
serie: "series_id_here"
}) {
id
state # Will be "SCHEDULED"
}
}
# 2. Activate season when airing begins
mutation {
activateseason(id: "season_id_here") {
id
state # Will be "ACTIVE"
}
}
# 3. Finalize season when completed
mutation {
finalizeseason(id: "season_id_here") {
id
state # Will be "FINISHED"
}
}
To include the total count in the extensions of your GraphQL response, Simfinity.js provides utility plugins for both Apollo Server and Envelop. This is particularly useful for pagination and analytics.
Use simfinity.plugins.envelopCountPlugin() to add count to extensions when using Envelop:
const { envelop, useSchema } = require('@envelop/core');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const simfinity = require('@simtlix/simfinity-js');
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
const getEnveloped = envelop({
plugins: [
useSchema(schema),
simfinity.plugins.envelopCountPlugin(), // Add the count plugin here
],
});
// Use getEnveloped in your server setup
Use simfinity.plugins.apolloCountPlugin() to add count to extensions when using Apollo Server:
const { ApolloServer } = require('apollo-server-express');
const simfinity = require('@simtlix/simfinity-js');
const server = new ApolloServer({
schema,
plugins: [
simfinity.plugins.apolloCountPlugin(), // Add the count plugin here
],
context: ({ req }) => {
// Your context setup
return {
user: req.user,
// count will be automatically added to extensions if present in context
};
},
});
simfinity.plugins.envelopCountPlugin() or simfinity.plugins.apolloCountPlugin() depending on your GraphQL server.context.count when count: true is specified in pagination, unless the root value supplies a count sink, as the MCP tools do). A context object is required; without one the rows are returned and the count query is skipped. A frozen, sealed or read-only context receives no count and logs one Configuration issue warning, so pass a fresh mutable context object for each request.extensions field of the GraphQL response.When the plugin is correctly set up, your GraphQL response will include the count in the extensions:
{
"data": {
"series": [
{
"id": "1",
"name": "Breaking Bad",
"categories": ["Crime", "Drama"],
"director": {
"name": "Vince Gilligan",
"country": "United States"
}
}
]
},
"extensions": {
"count": 15
}
}
This setup allows you to efficiently manage and display pagination information in your GraphQL applications.
Simfinity.js provides several utility methods for programmatic access to your GraphQL types and data:
getType(typeName)Retrieves a GraphQL type definition from the internal types registry.
Parameters:
typeName (string | GraphQLObjectType): The name of the type or a GraphQL type objectReturns:
GraphQLObjectType | null: The GraphQL type definition, or null if not foundExamples:
import { getType } from '@simtlix/simfinity-js';
// Get type by string name
const UserType = getType('User');
if (UserType) {
console.log(UserType.name); // 'User'
// Access field definitions
const fields = UserType.getFields();
console.log(Object.keys(fields)); // ['id', 'name', 'email', ...]
// Check specific field
const nameField = fields.name;
console.log(nameField.type); // GraphQLString
}
// Get type by GraphQL type object
const BookType = getType(SomeBookType);
// Safe access - returns null if not found
const nonExistentType = getType('NonExistent');
console.log(nonExistentType); // null
Use Cases:
getTypeWhen you have types that reference each other (like User and Group), using getType prevents circular import issues:
import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
import { getType } from '@simtlix/simfinity-js';
// User type that references Group
const UserType = new GraphQLObjectType({
name: 'User',
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
email: { type: GraphQLString },
// Reference Group type by name to avoid circular imports
groups: {
type: new GraphQLList(() => getType('Group')), // Use getType instead of direct import
extensions: {
relation: {
connectionField: 'members',
displayField: 'name'
}
}
},
// Single group reference
primaryGroup: {
type: () => getType('Group'), // Lazy evaluation with getType
extensions: {
relation: {
connectionField: 'primaryGroupId',
displayField: 'name'
}
}
}
})
});
// Group type that references User
const GroupType = new GraphQLObjectType({
name: 'Group',
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
description: { type: GraphQLString },
// Reference User type by name to avoid circular imports
members: {
type: new GraphQLList(() => getType('User')), // Use getType instead of direct import
extensions: {
relation: {
connectionField: 'groups',
displayField: 'name'
}
}
},
// Single user reference (admin)
admin: {
type: () => getType('User'), // Lazy evaluation with getType
extensions: {
relation: {
connectionField: 'adminId',
displayField: 'name'
}
}
}
})
});
// Register types with simfinity
simfinity.connect(null, UserType, 'user', 'users');
simfinity.connect(null, GroupType, 'group', 'groups');
// Create schema - resolvers will be auto-generated for all relationships
const schema = simfinity.createSchema();
Benefits of this approach:
getType without importing other type definitionsFile Structure Example:
types/
├── User.js // Defines UserType using getType('Group')
├── Group.js // Defines GroupType using getType('User')
└── index.js // Registers all types and creates schema
// types/User.js
import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
import { getType } from '@simtlix/simfinity-js';
export const UserType = new GraphQLObjectType({
name: 'User',
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
groups: {
type: new GraphQLList(() => getType('Group')),
extensions: { relation: { connectionField: 'members' } }
}
})
});
// types/Group.js
import { GraphQLObjectType, GraphQLID, GraphQLString, GraphQLList } from 'graphql';
import { getType } from '@simtlix/simfinity-js';
export const GroupType = new GraphQLObjectType({
name: 'Group',
fields: () => ({
id: { type: GraphQLID },
name: { type: GraphQLString },
members: {
type: new GraphQLList(() => getType('User')),
extensions: { relation: { connectionField: 'groups' } }
}
})
});
// types/index.js
import { UserType } from './User.js';
import { GroupType } from './Group.js';
import simfinity from '@simtlix/simfinity-js';
// Register all types
simfinity.connect(null, UserType, 'user', 'users');
simfinity.connect(null, GroupType, 'group', 'groups');
// Create schema with auto-generated resolvers
export const schema = simfinity.createSchema();
getModel(gqltype)Retrieves the Mongoose model associated with a GraphQL type.
Parameters:
gqltype (GraphQLObjectType): The GraphQL type objectReturns:
MongooseModel: The associated Mongoose modelExample:
const BookModel = simfinity.getModel(BookType);
const books = await BookModel.find({ author: 'Douglas Adams' });
getInputType(type)Retrieves the input type for mutations associated with a GraphQL type.
Parameters:
type (GraphQLObjectType): The GraphQL type objectReturns:
GraphQLInputObjectType: The input type for mutations. It has no fields for a type without writable fields, which gets no generated add mutation; give a registered mutation that creates such records no input or one of its own.Example:
const BookInput = simfinity.getInputType(BookType);
console.log(BookInput.getFields()); // Input fields for mutations
saveObject(typeName, args, session?, context?)Programmatically save an object through the creation pipeline, including validation, controllers, and nested writes.
Parameters:
typeName (string): The name of the GraphQL typeargs (object): The data to savesession (MongooseSession, optional): Caller-owned session with an active transaction, from the same MongoDB client as the registered modelcontext (object, optional): GraphQL context object (includes request info, user data, etc.); controller hooks and the middleware of nested collection children receive this value, so pass the hook's or request's contextReturns:
Promise<object>: The saved objectWithout a session, saveObject() starts a transaction on the registered model's connection, commits the parent and nested writes together, and awaits session cleanup. The same bounded transaction and commit retries described above apply. This requires a transaction-capable MongoDB deployment, such as a replica set.
With a session, it participates in the caller's active transaction. The caller owns commit, retry and cleanup. In default Mongo mode it also owns abort; with transactional reference integrity, a constraint violation or guarded-write error aborts even a supplied transaction to prevent committing an invalid write. That mode requires snapshot read concern and majority write concern. An inactive supplied session is rejected with ACTIVE_TRANSACTION_REQUIRED (400) before writes. Pass the provided session when calling from a controller or custom mutation so the writes share its transaction; a custom mutation's session can belong to another MongoDB client than the model's, see which connection the session uses. Calling saveObject() directly skips GraphQL input coercion, field authorization and the global middleware of the root record. Nested non-embedded collection children (added, updated, deleted) still run their target type's global middleware, controllers and ownership checks, with the context you pass, which is undefined when omitted. Without a session, a rejection rolls back the whole workflow; with a supplied session the error reaches the caller, who owns the abort in default MongoDB mode. Validate and authorize programmatic callers accordingly.
Example:
const newBook = await simfinity.saveObject('Book', {
title: 'New Book',
author: 'Author Name'
}, session, context);
// Without context (context will be undefined in controller hooks and in the middleware of nested collection children)
const newBook = await simfinity.saveObject('Book', {
title: 'New Book',
author: 'Author Name'
}, session);
Note: When context is not provided, it is undefined in controller hooks and in the middleware that nested collection children run. This is acceptable for programmatic usage where context may not be available.
updateObject(typeName, input, session?, context?), deleteObject(typeName, target, session?, context?) and transitionObject(typeName, actionName, input, session?, context?)Available since 3.16.0. Programmatically run the lifecycle of the generated update, delete and state action mutations: validators, controllers, nested collection entries, ownership checks and the version precondition.
Parameters:
typeName (string): The name of a type registered in this runtime that stores records. updateObject() and transitionObject() also need its generated update input, which has an id fieldinput (object): The shape of the type's update input (version included on a protected type). Only declared fields, plain objects and dense arrays; no state; internal enum values; scalars as primitives, Date or plain data; record keys as stringstarget (object): { id }, plus version on a type with optimistic concurrency, for deleteObject()actionName (string): A key of the type's state machine actionssession (optional): Caller-owned session with an active transaction, as for saveObject(). Without one, the helper owns its transaction and the adapter retries itcontext (object, optional): Reaches the controllers and the middleware of nested collection children; may be undefinedReturns:
Promise<object>: The updated record, the deleted record or null when no record has that ID (deleteObject()), or the record in the action's target state (transitionObject())The target and the input are checked before anything runs, and the input is copied, so the caller's object is never written to and a retry starts from the same data: INVALID_OPERATION_TARGET (500) and UNKNOWN_ACTION (500) for the target, REQUIRED_VALUE (400) for a missing input, id, version or required value, and INVALID_INPUT (400) for anything else, with a message that names the path and never the value. Like saveObject(), they skip GraphQL coercion, field authorization and the global middleware of the root record, and they never commit, retry or abort a supplied session. They apply maxNestedOperations to the nested entries of input (not deleteObject(), which has none). See the API reference.
Example:
const updated = await simfinity.updateObject('Book', { id: String(book._id), title: 'Second Edition' }, session, context);
const deleted = await simfinity.deleteObject('Book', { id: String(book._id) }, session, context);
const shipped = await simfinity.transitionObject('Order', 'ship', { id: String(order._id) }, session, context);
createSchema(includedQueryTypes?, includedMutationTypes?, includedCustomMutations?, options?)Creates the final GraphQL schema with all connected types.
Parameters:
includedQueryTypes (array, optional): Limit query types to includeincludedMutationTypes (array, optional): Limit mutation types to includeincludedCustomMutations (array, optional): Limit custom mutations to includeoptions (object, optional): { includedCustomQueries, transaction }. includedCustomQueries lists the registered custom query names to include: omitted or null includes all, [] includes none, and unregistered names are ignored. Since 3.16.0, transaction: { name?, types } adds the transaction mutation over the generated mutations of types, and requires the adapter to implement assertTransactionModels (TRANSACTION_UNSUPPORTED otherwise). A value that is not a plain object, such as an array, and any other key throw INVALID_SCHEMA_OPTIONS (500); an invalid transaction option throws it too: its shape, name and types, a name that collides with a registered mutation or with a root name the included types can generate, and the lack of an included endpoint type fail before any model is prepared, and only a collision with the built schema (the name against the roots actually built, or a derived type name that is already taken) and the lack of any operation left in it fail once the models are preparedReturns:
GraphQLSchema: The complete GraphQL schemaExample:
const schema = simfinity.createSchema();
Built with ❤️ by Simtlix
extensions: { queryable: true }groupIdget_by_id, the id filter is automatically combined with scope filterscontext.user, context.ip, or other context properties to determine scopeDate values compare by time, also in referenced in lists. A Date compared with any non-null value that is not a valid Date is invalid, and an invalid Date never matches. NaN never matches: comparing it with a non-null value is invalid, and a literal NaN operand throws TypeError at configuration time. Promise operands are invalid{ allOf: [] } is true, { anyOf: [] } is false, and { not: false } is true. Multiple operator keys form an implicit ANDeval() or Function() - pure object traversaloutputSchemanulltype: ['string', 'null']nullnullstructuredContentRecordVersion9007199254740991typeIntFloatStringBooleanIDcreateValidatedScalarserializetypeformatDateDateTimeTimeYYYY-MM-DDbaseScalarTypeDecimalFloat'12.30'formatTimeformat: 'time'serializetotalCountpaginationtruncatedlimits.maxResultBytes__totalCount__truncatedannotations: behavioral hints — every tool has openWorldHint: false; queries are readOnlyHint: true; generated deletes are destructiveHint: true and idempotentHint: true; generated unprotected updates are idempotentHint: true unless their input can carry collection items to insert (an added list at any depth, such as a non-embedded one-to-many collection), which every repeat inserts again, so they get idempotentHint: false. Protected updates keep idempotentHint: true even with added items: repeating the same version cannot apply another revision. Creates, state-machine actions and custom mutations get readOnlyHint: false only. Idempotency describes the stored data: a repeat runs middleware and controller hooks again, and a repeated delete calls onDelete with null, so onDelete hooks must tolerate null. Generated mutations are recognized by the extensions.simfinityMutation marker core stamps on them, or, in a schema without it (rebuilt from SDL, or hand-built), by the placeholder description ('add'/'update'/'delete') together with the matching name prefix — never by name alone — so a custom mutation or state-machine action that happens to be named updateReport or deleteDraft_book is not published as a CRUD tool. A description-less state-machine action is described as a transition between its states. A query is a list tool only with a pagination argument and a get-by-id tool only with an id argument (generated list and aggregate queries are recognized by extensions.simfinityQuery); any other query is a read-only custom query.nestedCollections |
'all' |
'none' leaves nested collections (object list fields with a pagination argument, such as non-embedded one-to-many relations) out of the generated selection set and outputSchema. Also per tool in toolOverrides, where the tool's value wins; ignored with selection. Other values throw MCP_INVALID_OPTIONS at setup. |
textFormat | 'pretty' | JSON format of the text content block: 'pretty' (two-space indentation) or 'compact'. maxResultBytes measures that text. Other values throw MCP_INVALID_OPTIONS at setup. |
includeId | — | Deprecated and ignored: it never changed the generated selection. Every scalar/enum field without required arguments, id included, is always selected. |
toolNamePrefix | — | Prefix prepended to every published tool name (e.g. 'catalog_'). Must match /^[a-zA-Z0-9_-]+$/; the resulting names must match /^[a-zA-Z0-9_-]{1,128}$/ (MCP_INVALID_TOOL_NAME otherwise). |
transactionTools | false | Since 3.16.0, true publishes the schema's generated transaction field as one destructive, non-idempotent tool. Off, it and any mutation shaped like it are never published, including from rebuilt schemas without descriptions or any individual operation roots. include/exclude accept it by name or 'mutation', and it is omitted when exclude names any of its operation keys. A call whose operations is not a list of items that each name exactly one advertised operation is rejected with MCP_UNKNOWN_ARGUMENT. Other values throw MCP_INVALID_OPTIONS. A schema rebuilt from introspection needs getIntrospectionQuery({ oneOf: true }) with descriptions. See the MCP guide. |
toolOverrides | {} | Per-tool overrides keyed by tool (or unprefixed field) name: { description, title, annotations, selectionDepth, nestedCollections, selection } (a deprecated includeId is ignored). See Customizing tools. |
toolMiddleware | — | A koa-style async (call, next) function, or an array of them, run around every tool call. A single function is a one-element stack. Validated at setup (MCP_INVALID_MIDDLEWARE, also for an empty slot in a sparse array) and copied, so later changes to the array have no effect. Middleware may be synchronous; a throw rejects the previous middleware's next() promise. See Tool middleware. |
limits | — | Guardrails: { maxPageSize, defaultPagination, maxResultBytes }, validated at setup (MCP_INVALID_LIMITS) and read again on every call; a falsy value means none. See Limits. |
schemaPlugins | — | Envelop-style plugins whose onSchemaChange hook is applied once before serving (in-process execution only). Entries are validated before any hook runs (MCP_INVALID_SCHEMA_PLUGIN, also for a misspelling of onSchemaChange on a plugin that lacks it); false/null/undefined entries are skipped; asynchronous hooks are awaited. Typed Envelop/Yoga Plugin arrays and class instances, including ones with call or apply members, are accepted by the TypeScript declarations (EnvelopSchemaPlugin, core's declaration, or SchemaPluginObject); functions and promises are not. See Authentication. |
serverName / serverVersion | 'simfinity-mcp' / '1.0.0' | Identity reported by the MCP server. |
transportOptions | — | createHTTPMCPHandler only: stateless SDK transport options (e.g. enableDnsRebindingProtection, allowedHosts, allowedOrigins). Setting sessionIdGenerator, onsessioninitialized, onsessionclosed or eventStore raises MCP_INVALID_TRANSPORT_OPTIONS at setup. |
onError | — | createHTTPMCPHandler only: (err, req, res) callback invoked when a request fails; the handler still responds with a JSON-RPC internal error (HTTP 500) if headers were not sent. |
maxTransactionOperations0'0'NaNnullfalse''InfinitytrueMCP_INVALID_EXECUTION_CONFIGretry re-issues failed queries only — never mutations — after network errors/timeouts, HTTP 5xx and 429 responses. Backoff is linear: the first retry waits backoffMs, the second 2 × backoffMs, and so on (default backoffMs: 250). Two opt-ins change it, and only for exactly true: retryAfter: true makes the next retry wait at least the Retry-After (delta-seconds, or an HTTP-date in the IMF-fixdate, RFC 850 or asctime form, read as GMT) of a retried 429 or 5xx response, and ends the retries at once with that response's error when it asks for more than maxRetryAfterMs (default 10000; this bounds each wait, not the total); jitter: true adds a random wait of up to half of backoffMs × k to retry k.callTool, stdio and createMCPServer over a persistent transport. The stateless createHTTPMCPHandler serves an MCP cancellation notification as a separate request, which cannot reach the call in flight, so only a closed HTTP connection cancels a call there.isError tool results carrying GraphQL-shaped errors with the codes MCP_REMOTE_HTTP_ERROR (non-2xx response without a valid GraphQL body, includes extensions.status), MCP_REMOTE_REQUEST_FAILED (network error/timeout, including body-read failures, headers that cannot be sent and an endpoint fetch cannot request, reported without the URL) and MCP_REMOTE_INVALID_RESPONSE (non-JSON body or invalid GraphQL response shape). A valid body has object data or a nonempty errors array with string messages; data: null requires errors. Invalid successful HTTP responses are not retried. Client cancellation rejects instead of returning an error tool result.extensions.count (e.g. exposed via simfinity.plugins.envelopCountPlugin()), it is surfaced as _meta.count on the tool result, and also as totalCount next to the data when it is a non-negative integer.MCP_RESULT_TOO_LARGEundefined) returns isError: true with MCP_UNKNOWN_ARGUMENT and nothing runs — no GraphQL operation and no remote request — so a corrected retry is safe. The message names the keys, suggests a close declared name (a case-insensitive match, or one or two edits away depending on length; id only for a case-insensitive match) and lists the valid arguments, e.g. Unknown arguments "titel", "limit" for tool "books". Did you mean "title" instead of "titel"? Valid arguments: title, pagination. The tool was not executed.isError: true with a text payload of { errors, data? } — partial data is preserved alongside errors when it fits maxResultBytes. structuredContent is omitted on errors.isError results (see Remote execution and Limits for the codes).callTool and getOperation throw MCP_TOOL_NOT_FOUND. An aborted extra.signal throws MCP_CALL_CANCELLED before execution, including cancellation while awaiting an asynchronous context factory. This prevents starting the GraphQL operation after cancellation; it does not undo or cancel database work already started.