![Simfinity.js โ€” Define once. Build beyond. GraphQL, MongoDB, PostgreSQL, and optional MCP.](.github/assets/readme-cover.png) # Simfinity.js 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](https://simtlix.github.io/simfinity.js/): start with the [quick start](https://simtlix.github.io/simfinity.js/guide/getting-started.html), explore the [guides](https://simtlix.github.io/simfinity.js/guide/schema.html), or consult the [API reference](https://simtlix.github.io/simfinity.js/reference/api.html). The website source is in [`docs/`](docs/). For a complete application, run the [Barber examples](examples/barber/README.md): 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](examples/barber/README.md#validate-changes). Run the documentation website locally with Node.js 22.12.0+: ```sh npm run docs:install npm run docs:dev ``` For builds and hosting, see the [website maintainer guide](docs/.vitepress/README.md). The website documents the current source; some older examples later in this README retain historical conventions. > **Documentation for both databases:** The [public website](https://simtlix.github.io/simfinity.js/guide/databases.html) 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. ## ๐Ÿ“‘ Table of Contents - [Features](#-features) - [Installation](#-installation) - [PostgreSQL support](#postgresql-support) - [Quick Start](#-quick-start) - [Core Concepts](#-core-concepts) - [Connecting Models](#connecting-models) - [Creating Schemas](#creating-schemas) - [MongoDB Reference Integrity](#mongodb-reference-integrity) - [Global Configuration](#global-configuration) - [Basic Usage](#-basic-usage) - [Automatic Query Generation](#automatic-query-generation) - [Automatic Mutation Generation](#automatic-mutation-generation) - [Filtering and Querying](#filtering-and-querying) - [Collection Field Filtering](#collection-field-filtering) - [Relationships](#-relationships) - [Defining Relationships](#defining-relationships) - [Auto-Generated Resolve Methods](#auto-generated-resolve-methods) - [Adding Types Without Endpoints](#adding-types-without-endpoints) - [Embedded vs Referenced Relationships](#embedded-vs-referenced-relationships) - [Querying Relationships](#querying-relationships) - [Validations](#-validations) - [Field-Level Validations](#field-level-validations) - [Type-Level Validations](#type-level-validations) - [Custom Validated Scalar Types](#custom-validated-scalar-types) - [Custom Error Classes](#custom-error-classes) - [Optimistic concurrency](#optimistic-concurrency) - [State Machines](#-state-machines) - [Controllers & Lifecycle Hooks](#๏ธ-controllers--lifecycle-hooks) - [Hook Parameters](#hook-parameters) - [After-commit callbacks](#after-commit-callbacks) - [Query Scope](#-query-scope) - [Overview](#overview) - [Defining Scope](#defining-scope) - [Scope for Find Operations](#scope-for-find-operations) - [Scope for Aggregate Operations](#scope-for-aggregate-operations) - [Scope for Get By ID Operations](#scope-for-get-by-id-operations) - [Scope Function Parameters](#scope-function-parameters) - [Authorization](#-authorization) - [Quick Start](#quick-start-1) - [Permission Schema](#permission-schema) - [Rule Helpers](#rule-helpers) - [Policy Expressions (JSON AST)](#policy-expressions-json-ast) - [Integration with GraphQL Yoga / Envelop](#integration-with-graphql-yoga--envelop) - [Legacy: Integration with graphql-middleware](#legacy-integration-with-graphql-middleware) - [Middlewares](#-middlewares) - [Adding Middlewares](#adding-middlewares) - [Middleware Parameters](#middleware-parameters) - [Common Use Cases](#common-use-cases) - [Advanced Features](#-advanced-features) - [Field Extensions](#field-extensions) - [Custom Queries](#custom-queries) - [Named Views](#named-views) - [Custom Mutations](#custom-mutations) - [Working with Existing Mongoose Models](#working-with-existing-mongoose-models) - [Programmatic Data Access](#programmatic-data-access) - [MCP Generation](#-mcp-generation) - [Generating tool definitions](#generating-tool-definitions) - [Standalone MCP server (stdio)](#standalone-mcp-server-stdio) - [HTTP MCP endpoint (alongside GraphQL)](#http-mcp-endpoint-alongside-graphql) - [Options](#options) - [Selecting and naming tools](#selecting-and-naming-tools) - [Customizing tools](#customizing-tools) - [Limits](#limits) - [Tool middleware](#tool-middleware) - [Remote execution](#remote-execution) - [Tool results and errors](#tool-results-and-errors) - [Authentication](#authentication) - [Aggregation Queries](#-aggregation-queries) - [Complete Example](#-complete-example) - [Resources](#-resources) - [License](#-license) - [Contributing](#-contributing) ## โœจ Features - **Automatic Schema Generation**: Define your object model, and Simfinity.js generates all queries and mutations - **MongoDB or PostgreSQL**: Choose the database facade once during application startup - **Powerful Querying**: Typed filters, nested paths, pagination, sorting, and aggregations across the supported contract - **Aggregation Queries**: Built-in support for GROUP BY queries with aggregation operations (SUM, COUNT, AVG, MIN, MAX) - **Auto-Generated Resolvers**: Automatically generates resolve methods for relationship fields - **Automatic Index Creation**: Generates MongoDB indexes for ObjectId fields and single references, including leaves inside embedded objects and embedded arrays. The top-level `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 reference](./docs/reference/extensions.md#automatic-mongodb-indexes) - **Business Logic**: Implement business logic and domain validations declaratively - **State Machines**: Built-in support for declarative state machine workflows - **Lifecycle Hooks**: Controller methods for granular control over operations - **Custom Validation**: Field-level and type-level custom validations - **Relationship Management**: Support for embedded and referenced relationships - **Authorization**: Production-grade GraphQL authorization with RBAC/ABAC, function-based rules, declarative policy expressions, and native Envelop/Yoga plugin support ## ๐Ÿ“ฆ Installation ```bash npm install mongoose@^8.24.2 graphql@^16.11.0 @simtlix/simfinity-js@3.15.0 ``` **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. ## PostgreSQL support 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: ```javascript 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](docs/guide/sql-plugins.md) 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](docs/resources/contributing.md#release-a-new-version). The [shared development and publication contract](docs/resources/contributing.md#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](docs/guide/schema.md#what-gets-generated). 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](docs/guide/postgresql.md), detailed [storage reference](docs/postgresql.md), and [compatibility contract](docs/compatibility.md) 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: ```sh npm install @simtlix/simfinity-mcp@3.15.0 @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. ## ๐Ÿš€ Quick Start The [complete quick start](docs/guide/getting-started.md) includes MongoDB setup, installation, and working create, read, update, and delete operations. Simfinity uses ES modules and named exports. ```sh 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`: ```javascript 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](docs/guide/getting-started.md#2-start-mongodb), then run `node server.js` and open `http://127.0.0.1:4000/graphql`: ```graphql mutation { addserie(input: { name: "The Expanse" }) { id name } } ``` ```graphql query { series { id name } } ``` For a full working application, see the [Barber examples](examples/barber/README.md), with MongoDB and PostgreSQL backends, a shared frontend, relationships, state machines, controllers, and authorization. ## ๐Ÿ”ง Core Concepts ### Connecting Models The `simfinity.connect()` method links your GraphQL types to Simfinity's automatic schema generation: ```javascript 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 ); ``` ### Creating Schemas Generate your complete GraphQL schema with optional type filtering: ```javascript 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 } // Optional: Options object; custom query names to include ); ``` `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: ```javascript 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](docs/reference/client-descriptor.md) 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](docs/guide/schema.md#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](docs/guide/schema.md#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](docs/reference/extensions.md). ### MongoDB Reference Integrity Opt into reference checks when creating the MongoDB adapter. The default remains `'off'`, preserving existing applications: ```javascript 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](docs/guide/mongodb-integrity.md) for snapshot/majority sessions, reserved lock metadata, contention and existing-data migration. ### Global Configuration ```javascript // 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. ## ๐Ÿ“‹ Basic Usage ### Automatic Query Generation Simfinity automatically generates queries for each connected type: ```javascript // For a BookType, you get: // - book(id: ID): Book - Get single book by ID // - books(...filters): [Book] - Get filtered list of books ``` ### Automatic Mutation Generation Simfinity automatically generates mutations for each connected type: ```javascript // 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](docs/guide/schema.md#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](docs/guide/relationships.md#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](docs/guide/mutations.md#update-selected-fields). ### Filtering and Querying 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](docs/guide/queries.md#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](#-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](docs/guide/queries.md#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](docs/guide/queries.md#collection-frames) and the [3.14.0 upgrade notes](docs/resources/compatibility.md#collection-frames). 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. 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: ```graphql 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 } } ``` #### Available Operators - `EQ` - Equal - `NE` - Not equal - `GT` - Greater than - `LT` - Less than - `GTE` - Greater than or equal - `LTE` - Less than or equal - `IN` - In array - `NIN` - Not in array - `BTW` - Between two values - `LIKE` - Literal substring, respecting case - `ILIKE` - Literal substring, ignoring case - `STARTS_WITH`, `ISTARTS_WITH` - Literal prefix, respecting or ignoring case - `ENDS_WITH`, `IENDS_WITH` - Literal suffix, respecting or ignoring case - `IEQ` - Equal, ignoring case - `IS_EMPTY`, `IS_NOT_EMPTY` - The field holds no value, or holds one; they take no value The 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](docs/guide/queries.md#text-operators) for the rules, the differences and the PostgreSQL indexes that serve them, which `caseFoldExpression()` writes. A custom database adapter or SQL plugin receives them 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](docs/guide/queries.md#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](docs/guide/queries.md#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. ### Logical Filters (AND / OR) 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. #### Simple OR Return books in either the Sci-Fi or Fantasy category: ```graphql query { books( OR: [ { conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] } { conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] } ] ) { id title category } } ``` #### Flat Filters Combined with OR 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: ```graphql 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")`. #### Nested AND inside OR ```graphql 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")`. #### Filtering on Relationships within AND/OR Use the `path` parameter to filter on related entity fields: ```graphql 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`: ```graphql 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. #### Mixing Flat Filters with AND/OR 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: ```graphql 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: ```graphql 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")`. #### Collection Filtering with AND/OR 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: ```graphql 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: ```graphql 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"))`. #### Filter Types Reference | 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. - Multiple `conditions` in the same group are combined with AND - Maximum nesting depth: 5 levels ### Collection Field Filtering Simfinity.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. #### Basic Collection Filtering Filter collection fields using the same operators and format as main queries: ```graphql query { series { seasons(number: { operator: EQ, value: 1 }) { number id year } } } ``` #### Advanced Collection Filtering You can use complex filtering with nested object properties: ```graphql query { series { seasons( year: { operator: GTE, value: 2020 } episodes: { terms: [ { path: "name", operator: LIKE, value: "Pilot" } ] } ) { number year episodes { name date } } } } ``` #### Collection Filtering with Multiple Conditions Combine multiple filter conditions for collection fields: ```graphql query { series { seasons( number: { operator: GT, value: 1 } year: { operator: BTW, value: [2015, 2023] } ) { number year state } } } ``` #### Nested Collection Filtering Filter deeply nested collections using dot notation: ```graphql query { series { seasons( episodes: { terms: [ { path: "name", operator: LIKE, value: "Final" } ] } ) { number episodes { name date } } } } ``` #### Collection Filtering with Array Operations Use array operations for collection fields: ```graphql 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`, `BTW`, `LIKE`, `ILIKE`, `STARTS_WITH`, `ISTARTS_WITH`, `ENDS_WITH`, `IENDS_WITH`, `IEQ`, `IS_EMPTY`, `IS_NOT_EMPTY`) work with collection fields. ## ๐Ÿ”— Relationships ### Defining Relationships Use the `extensions.relation` field to define relationships between types: ```javascript 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! ๐ŸŽ‰ }, }), }); ``` ### Relationship Configuration - `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 components - `embedded`: **(Optional)** Whether the relation is embedded (default: false) ### Auto-Generated Resolve Methods ๐ŸŽ‰ **NEW**: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate. #### Before (Manual Resolvers) ```javascript 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 }); } } }), }); ``` #### After (Auto-Generated Resolvers) ```javascript 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! ๐ŸŽ‰ } }), }); ``` #### How It Works - **Single Object Relationships**: Automatically generates `findById()` resolvers using the field name or `connectionField` - **Collection Relationships**: Automatically generates `find()` resolvers using the `connectionField` to query related objects - **Lazy Loading**: Models are looked up at runtime, so types can be connected in any order - **Batched References**: Within a request with an object context, unscoped single references can share a batched read. MongoDB models with any `find` 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](https://simtlix.github.io/simfinity.js/guide/relationships.html#query-related-records). - **Batched Collections**: With an adapter that batches collection reads, the collection fields of one request that share a related type, a connection field and their arguments, after middleware and scopes, are read together, up to 100 parents per read, while middleware, scopes and auth rules still run per field. Both bundled adapters do this only with `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](https://simtlix.github.io/simfinity.js/guide/relationships.html#batched-collection-reads). - **Backwards Compatible**: Existing manual resolve methods are preserved and not overwritten. A field with a manual resolver cannot be used in client filter, sort or aggregation paths unless it sets `extensions: { queryable: true }` - **Type Safety**: Clear error messages if related types aren't properly connected #### Connect Your Types ```javascript // 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. ### Adding Types Without Endpoints Use `addNoEndpointType()` for types that should be included in the GraphQL schema but don't need their own CRUD operations: ```javascript 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 | #### Perfect Example: TV Series with Embedded Director From the [series-sample](https://github.com/simtlix/series-sample) project: ```javascript // 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 available - โœ… `serie`, `series` queries available - โŒ No `adddirector`, `director`, `directors` endpoints (director is embedded) **Usage:** ```graphql mutation { addserie(input: { name: "Breaking Bad" categories: ["crime", "drama", "thriller"] director: { name: "Vince Gilligan" country: "United States" } }) { id name director { name country } } } ``` #### When to Use Each Approach **Use `addNoEndpointType()` for:** - Simple data objects with few fields - Data that doesn't need CRUD operations - Objects that belong to a single parent (1:1 relationships) - Configuration or settings objects - **Examples**: Address, Director info, Product specifications **Use `connect()` for:** - Complex entities that need their own endpoints - Data that needs CRUD operations - Objects shared between multiple parents (many:many relationships) - Objects with business logic (controllers, state machines) - **Examples**: User, Product, Order, Season, Episode ### Embedded vs Referenced Relationships **Referenced Relationships** (default): ```javascript // 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**: ```javascript // 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](docs/guide/mutations.md#update-selected-fields). ### Querying Relationships Query nested relationships with dot notation: ```graphql query { books(author: { terms: [ { path: "country.name", operator: EQ, value: "England" } ] }) { id title author { name country { name } } } } ``` ### Creating Objects with Relationships **Link to existing objects:** ```graphql mutation { addBook(input: { title: "New Book" author: { id: "existing_author_id" } }) { id title author { name } } } ``` **Create embedded objects:** ```graphql mutation { addBook(input: { title: "New Book" publisher: { name: "Penguin Books" location: "London" } }) { id title publisher { name location } } } ``` ### Collection Fields Work with arrays of related objects: ```graphql 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](docs/guide/mutations.md#limit-nested-collection-operations). ## โœ… Validations ### Declarative Validation Helpers Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code. #### Using Validators ```javascript 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') } } }) }); ``` #### Available Validators **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 length - `validators.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 format - `validators.url()` - Validates URL format **Number Validators:** - `validators.numberRange(name, min, max)` - Validates number range (omit a bound with `undefined` or `null`) - `validators.positive(name)` - Ensures number is positive **Array 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 them **Date 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 format #### Validator Features - **Automatic Operation Handling**: Validators work for both `CREATE` (save) and `UPDATE` operations - **Smart Validation**: For CREATE operations, values are required. For UPDATE operations, undefined/null values are allowed (field might not be updated) - **Consistent Error Messages**: All validators throw `SimfinityError` with appropriate messages #### Example: Multiple Validators ```javascript const 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) } } }) }); ``` ### Field-Level Validations (Manual) For custom validation logic, you can still write manual validators: ```javascript 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] } } } }) }); ``` ### Type-Level Validations Validate objects as a whole: ```javascript 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 }) }); ``` ### Custom Validated Scalar Types Create custom scalar types with built-in validation. The generated type names follow the pattern `{name}_{baseScalarTypeName}`. #### Pre-built Scalars Simfinity.js provides ready-to-use validated scalars for common patterns: ```javascript 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`) #### Factory Functions for Custom Scalars Create custom validated scalars with parameters: ```javascript 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`) #### Creating Custom Scalars Manually You can also create custom scalars using `createValidatedScalar` directly: ```javascript 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](docs/reference/scalars.md#createvalidatedscalar). ### Custom Error Classes Create domain-specific error classes: ```javascript 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: - A `SimfinityError` keeps its code and status. - A request `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`. - Any other `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. - Any other `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`. The formatter returns a `GraphQLError` whose `originalError` is the classified error. See [buildErrorFormatter](docs/reference/errors.md#builderrorformatter), [server integration](docs/reference/errors.md#connect-it-to-your-server) and [server differences](docs/reference/errors.md#server-differences). ## Optimistic concurrency 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](docs/guide/concurrency.md) 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. ## ๐Ÿ”„ State Machines Implement declarative state machine workflows: ### 1. Define States ```javascript 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' } } }); ``` ### 2. Define Type with State Field ```javascript 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). ### 3. Configure State Machine ```javascript 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' } } }; ``` ### 4. Connect with State Machine ```javascript simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine); ``` ### 5. Use State Machine Mutations The state machine automatically generates mutations for each action: ```graphql mutation { process_order(input: { id: "order_id" }) { id state customer } } ``` **Important Notes**: - The `state` field is automatically read-only and managed by the state machine - State transitions are only allowed based on the defined actions - Business logic in the `action` function is executed during transitions - Invalid transitions throw errors automatically ## ๐ŸŽ›๏ธ Controllers & Lifecycle Hooks Controllers provide fine-grained control over operations with lifecycle hooks: ```javascript 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); ``` ### Hook Parameters **`onSaving(doc, args, session, context)`**: - `doc`: Mongoose Document instance (not yet saved) - `args`: Raw GraphQL mutation input - `session`: Mongoose session for transaction - `context`: GraphQL context object (includes request info, user data, etc.) **`onSaved(doc, args, session, context)`**: - `doc`: Plain object of saved document - `args`: Raw GraphQL mutation input - `session`: Mongoose session for transaction - `context`: GraphQL context object (includes request info, user data, etc.) **`onUpdating(id, doc, session, context)`**: - `id`: Document ID being updated - `doc`: Plain object with only changed fields - `session`: Mongoose session for transaction - `context`: 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 completed - `session`: Mongoose session for transaction - `context`: GraphQL context object (includes request info, user data, etc.) **`onDelete(doc, session, context)`**: - `doc`: Plain object of document to be deleted - `session`: Mongoose session for transaction - `context`: 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. ### After-commit callbacks 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. ```javascript 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](docs/guide/controllers.md#after-commit-callbacks) and [configuration API](docs/reference/api.md#configureaftercommit). ### Using Context in Controllers 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: - **Setting ownership**: Automatically assign the current user as the owner of new entities - **Authorization checks**: Validate user permissions before allowing operations - **Audit logging**: Track who performed which operations - **User-specific business logic**: Apply different logic based on user roles or attributes **Example: Setting Owner on Creation** ```javascript 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** ```javascript 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 ### Overview 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. ### Defining Scope Define scope in the type extensions, similar to how validations are defined: ```javascript 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 for Find Operations Scope functions for `find` operations modify the query arguments that are passed to `buildQuery`. The modified arguments are automatically used to filter results: ```javascript 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 for Aggregate Operations Scope functions for `aggregate` operations work the same way, ensuring aggregation queries also respect the scope: ```javascript 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. ### Scope for Get By ID Operations 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: ```javascript 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: 1. Create a query that includes both the id filter and the owner scope filter 2. Only return the document if it matches both conditions 3. Return `null` if the document exists but doesn't match the scope ### Scope Function Parameters `extensions.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: ```javascript { 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.) } ``` ### Filter Structure When modifying `args` in scope functions, use the appropriate filter structure: **For scalar fields:** ```javascript args.fieldName = { operator: 'EQ', value: 'someValue' }; ``` **For object/relation fields (QLTypeFilterExpression):** ```javascript 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](docs/guide/queries.md#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). ### Complete Example Here's a complete example showing scope for all query operations: ```javascript 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' } } } }) }); ``` ### Important Notes - **Execution Order**: Scope functions are executed **after** middleware, so middleware can set up context (e.g., user info) that scope functions can use - **Modify Args In Place**: Scope functions should modify the `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 objects](docs/guide/middleware.md#keep-client-filter-objects) - **Filter Structure**: Use the correct filter structure (`QLFilter` for scalars, `QLTypeFilterExpression` for relations) - **All Query Operations**: Scope applies to `find`, `aggregate`, and `get_by_id` operations - **Generated Relationships**: Non-embedded single relations run the target type's `get_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. - **Relationship Paths**: When a client filter, sort, `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](docs/guide/query-scope.md#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 `groupId`/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. - **Custom Resolvers and Writes**: Existing relationship resolvers remain unchanged. Query scope does not authorize mutations; use global middleware or controller checks for write permissions, including permissions on nested children. - **Automatic Merging**: For `get_by_id`, the id filter is automatically combined with scope filters - **Context Access**: Use `context.user`, `context.ip`, or other context properties to determine scope ### Use Cases - **Multi-tenancy**: Filter documents by organization or tenant - **User-specific data**: Only show documents owned by the current user - **Role-based access**: Filter based on user roles or permissions - **Department/Team scoping**: Show only data relevant to user's department - **Geographic scoping**: Filter by user's location or region ## ๐Ÿ” Authorization 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. ### Quick Start ```javascript 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] }); ``` ### Permission Schema The permission schema defines authorization rules per type and field: ```javascript 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:** 1. Check exact field rule: `permissions[TypeName][fieldName]` 2. Fallback to wildcard: `permissions[TypeName]['*']` 3. Apply default policy (ALLOW or DENY) 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:** - **Function**: `(parent, args, ctx, info) => boolean | void | Promise` - **Nonempty array of rules**: All rules must pass (AND logic); functions, expressions, and nested nonempty arrays can be mixed - **Policy expression**: JSON AST object or boolean literal (see below) 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` โ†’ allow - `return false` โ†’ deny - `throw Error` โ†’ deny with error Only `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. ### Rule Helpers Simfinity.js provides reusable rule builders: ```javascript 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; ``` #### requireAuth(userPath?) 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. ```javascript 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'), }, }; ``` #### requireRole(role, options?) 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. ```javascript 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', }), }, }; ``` #### requirePermission(permission, options?) 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. ```javascript 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', }), }, }; ``` #### composeRules(...rules) Combines multiple rules with AND logic (all must pass): ```javascript const permissions = { Mutation: { updatePost: composeRules( requireAuth(), requireRole('EDITOR'), async (post, args, ctx) => post.authorId === ctx.user.id, ), }, }; ``` #### anyRule(...rules) Combines multiple rules with OR logic (any must pass): ```javascript const permissions = { Post: { content: anyRule( requireRole('ADMIN'), async (post, args, ctx) => post.authorId === ctx.user.id, ), }, }; ``` #### isOwner(ownerField, userIdField) 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. ```javascript const permissions = { Post: { '*': composeRules( requireAuth(), isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id ), }, }; ``` ### Policy Expressions (JSON AST) For declarative rules, use JSON AST policy expressions: ```javascript 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 arguments - `ctx.*` - GraphQL context **Security:** - Only `parent`, `args`, and `ctx` roots are allowed - Reference paths support root values, dotted fields, document getters, and numeric array indices. Empty path segments and `__proto__`, `prototype`, or `constructor` segments are invalid - `eq` 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 expression - Unknown operators or malformed nested expressions invalidate the entire configured policy, even inside an otherwise granting `anyOf` - Factories reject malformed expressions with `TypeError`; `isPolicyExpression` validates the complete AST, and direct `evaluateExpression` calls return `false` for malformed ASTs - Created rules classify operands as literals or references once. Adding `ref` 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 equality - Missing references and invalid runtime membership operands cannot grant access, including under `not` 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 user - Explicit `null`, `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 ownership - `Date` 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 - Boolean expressions and logical identities remain valid: `{ allOf: [] }` is true, `{ anyOf: [] }` is false, and `{ not: false }` is true. Multiple operator keys form an implicit AND - No `eval()` or `Function()` - pure object traversal ### Integration with GraphQL Yoga / Envelop The 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. ```javascript 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); ``` ### Legacy: Integration with graphql-middleware > **Deprecated:** `applyMiddleware` from `graphql-middleware` rebuilds the schema via `mapSchema`, > which can cause `"Schema must contain uniquely named types"` errors with Simfinity schemas. > Use `createAuthPlugin` with GraphQL Yoga / Envelop instead. > Simfinity does not install `graphql-middleware`; add it to your application to use this example. > `createFieldMiddleware` returns the same middleware function as `createAuthMiddleware`, so wildcard rules and the default policy apply to every field. ```javascript 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); ``` ### Plugin / Middleware Options ```javascript 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 | ### Error Handling The auth middleware uses Simfinity error classes: ```javascript 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: ```javascript 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; }, }, }; ``` ### Best Practices 1. **Default to DENY**: Use `defaultPolicy: 'DENY'` for security 2. **Use wildcards wisely**: `'*'` rules provide baseline security per type 3. **Prefer helper rules**: Use `requireAuth()`, `requireRole()` over custom functions 4. **Fail closed**: Custom rules should deny on unexpected conditions 5. **Keep rules simple**: Complex logic belongs in controllers, not auth rules 6. **Test thoroughly**: Auth rules are critical - test all scenarios ## ๐Ÿ”ง Middlewares Middlewares 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. ### Adding Middlewares Register middlewares using `simfinity.use()`. Middlewares execute in the order they're registered. `use()` accepts only functions; any other value throws `INVALID_MIDDLEWARE` (500): ```javascript // Basic logging middleware simfinity.use(async (params, next) => { console.log(`Executing ${params.operation} on ${params.type?.gqltype.name || params.entry}`); await next(); }); ``` ### Middleware Parameters 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. ```javascript 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(); }); ``` ### Common Use Cases #### 1. Authentication & Authorization ```javascript 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(); }); ``` #### 2. Request Logging & Monitoring ```javascript 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(); }); ``` #### 3. Input Validation & Sanitization ```javascript 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(); }); ``` #### 4. Rate Limiting ```javascript 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(); }); ``` #### 5. Audit Trail ```javascript 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(); }); ``` ### Multiple Middlewares Middlewares execute in registration order. Each middleware awaits `next()` to continue the chain: ```javascript // 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. ### Error Handling in Middlewares Middlewares can throw errors to stop the operation: ```javascript 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; } }); ``` ### Conditional Middleware Execution Execute middleware logic conditionally based on operation type or context: ```javascript 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(); }); ``` ### Best Practices 1. **Await `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 it 2. **Handle errors deliberately**: Catching an error around `next()` does not let the operation continue; use it for logging 3. **Keep middlewares focused**: Each middleware should handle one concern 4. **Order matters**: Register middlewares in logical order (auth โ†’ validation โ†’ logging) 5. **Performance consideration**: Middlewares run on every operation, keep them lightweight 6. **Use context wisely**: Store request-specific data in the GraphQL context object ## ๐Ÿ”ง Advanced Features ### Field Extensions Control field behavior with extensions: ```javascript 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](https://simtlix.github.io/simfinity.js/guide/queries.html#virtual-fields). ### Custom Queries 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: ```javascript 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(); ``` ```graphql 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](docs/guide/queries.md#custom-queries) for selection, transactions and authorization, and the [API reference](docs/reference/api.md#registercustomquery) for the full contract. ### Named Views 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. ```javascript 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](docs/guide/databases.md). Call the field through GraphQL with the same trusted request context as any generated query: ```javascript 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: ```json { "data": { "upcomingBookings": [ { "scheduledDate": "2026-11-02", "startTime": "09:00", "state": "CONFIRMED" } ] } } ``` The count is available as `context.count`, here `1`. Use the [count plugin](docs/guide/queries.md#pagination-and-total-count) 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](docs/guide/queries.md#named-views) for params, resolution and limits, and the [`registerView()` reference](docs/reference/api.md#registerview) for the registration contract. ### Custom Mutations Register custom mutations beyond the automatic CRUD operations: ```javascript 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' }; } ); ``` ### Adding Types Without Endpoints Include types in the schema without generating endpoints. See the [detailed guide on addNoEndpointType()](#adding-types-without-endpoints) for when and how to use this pattern: ```javascript // This type can be used in relationships but won't have queries/mutations simfinity.addNoEndpointType(AddressType); ``` ### Working with Existing Mongoose Models Use your existing Mongoose models: ```javascript 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](docs/guide/mutations.md#which-connection-the-session-uses-on-mongodb). ### Programmatic Data Access Access data programmatically outside of GraphQL: ```javascript // Save an object programmatically const newBook = await simfinity.saveObject('Book', { title: 'New Book', author: 'Author Name' }, 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); ``` For jobs, workers, webhooks and other server code, read [server-side operations](docs/guide/server-side-operations.md): 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](docs/guide/anti-patterns.md) lists the approaches to avoid. The optional [application ESLint plugin](docs/guide/linting.md) provides eleven rules 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/eslint-plugin-simfinity@0.3.1'`. 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. ## ๐Ÿค– MCP Generation Simfinity can automatically expose every generated GraphQL operation as a [Model Context Protocol](https://modelcontextprotocol.io) (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` (`_`) | 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, 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](#tool-results-and-errors)). Nested input objects are not closed in the schema; GraphQL already rejects their unknown fields. - **`outputSchema`**: JSON Schema describing the returned data (mirroring the auto-generated selection set), with type/field descriptions. Every nullable GraphQL position also accepts `null` (e.g. `type: ['string', 'null']`; enums get `null` appended), so validating MCP clients accept the `null`s GraphQL legitimately returns in `structuredContent` (get-by-id misses, unset optional fields). `RecordVersion` has integer bounds 1 through `9007199254740991` in inputs and outputs. Otherwise a scalar gets a JSON `type` only when it is a GraphQL spec scalar (`Int`, `Float`, `String`, `Boolean`, `ID`) or a `createValidatedScalar` chain ending at one. Any other scalar serializes with its own `serialize`, so it is published without a `type` or `format`: `Date`, `DateTime` and `Time` (an identity scalar sends an ISO instant, others epoch milliseconds or `YYYY-MM-DD`), custom scalars with a hand-set `baseScalarType` storage hint (such as a `Decimal` hinted as `Float` that sends `'12.30'`) and opaque scalars. Date/time scalars without a description get one saying their JSON form is defined by the server's scalar. Input schemas keep the `format` hints, so an input `Time` argument still advertises `format: 'time'` while outputs carry whatever `serialize` returns; hosts that validate input formats can reject a value copied from an output. Next to the root field, the schema declares the optional keys MCP adds: `totalCount` on list tools with a `pagination` argument, and `truncated` on mutation tools when `limits.maxResultBytes` is set (`__totalCount`/`__truncated` when the root field has that name). - **`annotations`**: 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. 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/sdk` dependency (`^1.31.0`). Install it with `npm install @modelcontextprotocol/sdk@^1.31.0`. `generateMCPTools` works 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 as `Server` โ€” install a supported version with `npm 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 run `schemaPlugins`, so an SDK error comes before any option error, `transportOptions` included, and leaves the schema untouched. ### Installation ```bash npm install @modelcontextprotocol/sdk@^1.31.0 ``` ### Generating tool definitions `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: ```javascript 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. ### Standalone MCP server (stdio) Use stdio for a standalone executable consumed by clients like Cursor or Claude Desktop: ```javascript import * as simfinity from '@simtlix/simfinity-js'; const schema = simfinity.createSchema(); await simfinity.startStdioMCPServer(schema, { serverName: 'my-api-mcp', context: { user: { id: 'system' } }, }); ``` ### HTTP MCP endpoint (alongside GraphQL) Mount an MCP endpoint (Streamable HTTP) next to your existing GraphQL server: ```javascript 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`: ```javascript 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. ### Options | 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](#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](https://simtlix.github.io/simfinity.js/guide/relationships.html#batched-collection-reads)). | | `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). | | `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](#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](#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](#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](#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. | 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. ### Selecting and naming tools Trim and namespace the published surface with `include`/`exclude`, `includeTypes`/`excludeTypes` and `toolNamePrefix`: ```javascript 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. - Duplicate tool names (e.g. a Query field and a Mutation field sharing a name) are not allowed by MCP: the first tool wins and the later one is skipped with a `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. ### Customizing tools `toolOverrides` tunes individual tools without touching the schema. Keys accept either the published tool name or the unprefixed field name: ```javascript 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: ```javascript 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 `limits` protects agents (and your database) from oversized requests and responses. Violations come back as regular tool results, not exceptions: ```javascript 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](#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. - None of these bound nested collections: with the default `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 })`. `limits` is validated when `generateMCPTools`, `createMCPServer`, `startStdioMCPServer` or `createHTTPMCPHandler` sets up, before `schemaPlugins` run, and invalid settings throw `MCP_INVALID_LIMITS`: - A falsy `limits`, `defaultPagination` or cap (`undefined`, `null`, `false`, `0`, `''`, as `cond && 100` produces) leaves it unset; a truthy `limits` or `defaultPagination` must be an object. - Caps must be numbers of at least 1. Numeric strings (e.g. from `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. - A `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. ### Tool middleware `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 }`: ```javascript 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`. - Middleware may be synchronous. When any middleware throws, synchronously or not, the previous middleware's `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. - Undeclared argument keys that the caller sent and that are still in `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. ### Remote execution `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`: ```javascript 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, `0`, `'0'`, `NaN`, `null`, `false` and `''` disable it; `Infinity`, negative, `true` and non-numeric values throw `MCP_INVALID_EXECUTION_CONFIG` at setup. It is read again on every call. - `retry` 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. - Client cancellation rejects during fetch, body reading or a retry backoff, at once and with the signal's abort reason, and is never retried. This holds for `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. - Transport failures surface as `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. - If the remote response carries a numeric `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. `schemaPlugins` is not applied in remote mode โ€” the remote GraphQL server enforces its own plugins. ### Tool results and errors 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: - **Total count**: with `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. - **Oversized mutation results**: when a mutation's result exceeds `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. Oversized query results keep the `MCP_RESULT_TOO_LARGE` error, whose message also reports the cap rounded down to whole bytes. - **Unknown arguments**: an argument key the caller sent that the tool does not declare (with a value other than `undefined`) 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.` - **Execution errors**: `isError: true` with a `text` payload of `{ errors, data? }` โ€” partial data is preserved alongside errors when it fits `maxResultBytes`. `structuredContent` is omitted on errors. - **Remote transport failures** and **limit violations** are also `isError` results (see [Remote execution](#remote-execution) and [Limits](#limits) for the codes). - **Unknown tool names** are the exception: `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. ### Authentication 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](#-query-scope) functions read the context on every execution and need no extra wiring. Field-level rules from [`createAuthPlugin`](#-authorization) 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): ```javascript 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: - A function, an array, a promise (an object with a `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. - So does a misspelling of `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. - Plugins carrying other hooks (`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`. - An asynchronous `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. ## ๐Ÿ“Š Aggregation Queries 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. ### Overview For each entity type registered with `connect()`, an additional aggregation endpoint is automatically generated with the format `{entityname}_aggregate`. ### Features - **Group By**: Group results by any field (direct or related entity field path) - **Aggregation Operations**: SUM, COUNT, AVG, MIN, MAX - **Filtering**: Use the same filter parameters as regular queries - **Sorting**: Sort by groupId or any calculated fact (metrics), with support for multiple sort fields - **Pagination**: Use the same pagination parameters as regular queries - **Related Entity Fields**: Group by or aggregate on fields from related entities using dot notation ### GraphQL Types #### QLAggregationOperation (Enum) - `SUM`: Sum of numeric values - `COUNT`: Count of records - `AVG`: Average of numeric values - `MIN`: Minimum value - `MAX`: Maximum value #### QLTypeAggregationFact (Input) ```graphql input QLTypeAggregationFact { operation: QLAggregationOperation! factName: String! path: String! } ``` #### QLTypeAggregationExpression (Input) ```graphql input QLTypeAggregationExpression { groupId: String! facts: [QLTypeAggregationFact!]! } ``` #### QLTypeAggregationResult (Output) ```graphql 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. ### Quick Examples #### Simple Group By ```graphql query { series_aggregate( aggregation: { groupId: "category" facts: [ { operation: COUNT, factName: "total", path: "id" } ] } ) { groupId facts } } ``` #### Group By Related Entity ```graphql query { series_aggregate( aggregation: { groupId: "country.name" facts: [ { operation: COUNT, factName: "count", path: "id" } { operation: AVG, factName: "avgRating", path: "rating" } ] } ) { groupId facts } } ``` #### Multiple Aggregation Facts ```graphql 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 } } ``` #### With Filtering ```graphql query { series_aggregate( rating: { operator: GTE, value: 8.0 } aggregation: { groupId: "category" facts: [ { operation: COUNT, factName: "highRated", path: "id" } ] } ) { groupId facts } } ``` #### Sorting by Multiple Fields ```graphql 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 } } ``` #### With Pagination (Top 5) ```graphql 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 } } ``` ### Field Path Resolution The `groupId` and `path` parameters support: 1. **Direct Fields**: Simple field names from the entity - Example: `"category"`, `"rating"`, `"id"` 2. **Related Entity Fields**: Dot notation for fields in related entities - Example: `"country.name"`, `"studio.foundedYear"` 3. **Nested Related Entities**: Multiple levels of relationships - Example: `"country.region.name"` ### Sorting Options - Sort by **groupId** or **any fact name** - **Multiple sort fields supported** - results are sorted by the first field, then by the second field for ties, etc. - Set the `field` parameter to: - `"groupId"` to sort by the grouping field - Any fact name (e.g., `"avgRating"`, `"total"`) to sort by that calculated metric - The `order` parameter (ASC/DESC) determines the sort direction for each field - If a field doesn't match groupId or any fact name, it defaults to groupId - If no sort is specified, defaults to sorting by groupId ascending ### Pagination Notes - Explicit `page` 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. - The `count` parameter is **ignored** for aggregation queries - Pagination is applied **after** grouping and sorting - Aggregation queries remain unbounded when pagination is omitted; they do not inherit the list default limit. ### MongoDB Translation Aggregation queries are translated to efficient MongoDB aggregation pipelines: 1. **$lookup**: Joins with related entity collections 2. **$unwind**: Flattens joined arrays 3. **$match**: Applies filters (before grouping) 4. **$group**: Groups by the specified field with aggregation operations 5. **$project**: Formats final output with groupId and facts fields 6. **$sort**: Sorts results by groupId or facts (with multiple fields support) 7. **$limit** / **$skip**: Applied for pagination (after sorting) ### Result Structure Results are returned in a consistent format: ```json { "groupId": , "facts": { "factName1": , "factName2": } } ``` For complete documentation with more examples, see [AGGREGATION_EXAMPLE.md](./packages/mongodb/AGGREGATION_EXAMPLE.md) and [AGGREGATION_CHANGES_SUMMARY.md](./packages/mongodb/AGGREGATION_CHANGES_SUMMARY.md). ## ๐Ÿ“š Complete Example Here's a complete bookstore example with relationships, validations, and state machines: ```javascript 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'); }); ``` ## ๐Ÿ”— Resources - **[Barber examples](examples/barber/README.md)** - A booking app with MongoDB and PostgreSQL backends, a shared frontend, GraphQL and MCP, and independent validation - **[Series Sample Project](https://github.com/simtlix/series-sample)** - A complete TV series microservice built with Simfinity.js demonstrating types, relationships, state machines, controllers, and authorization - **[Samples Repository](https://github.com/simtlix/simfinity.js-samples)** - Complete examples and use cases - **[MongoDB Query Language](https://docs.mongodb.com/manual/tutorial/query-documents/)** - Learn about MongoDB querying - **[GraphQL Documentation](https://graphql.org/learn/)** - Learn about GraphQL ## ๐Ÿ“„ License Apache-2.0 License - see the [LICENSE](LICENSE) file for details. ## ๐Ÿค Contributing Contributions are welcome! Please feel free to submit a Pull Request. --- *Built with โค๏ธ by [Simtlix](https://github.com/simtlix)* ## ๐Ÿ“š Query Examples from Series-Sample Here are some practical GraphQL query examples from the series-sample project, showcasing how to use simfinity.js effectively: ### 1. Series with Directors from a Specific Country Find all series that have directors from the United States: ```graphql query { series(director: { terms: [ { path: "country", operator: EQ, value: "United States" } ] }) { id name categories director { name country } } } ``` ### 2. Series with a Specific Episode Name Find series that contain an episode with the name "Pilot": ```graphql query { series( seasons: { terms: [ { path: "episodes.name", operator: EQ, value: "Pilot" } ] } ) { id name seasons { number episodes { number name date } } } } ``` ### 3. Series with a Particular Star Find series that feature "Bryan Cranston": ```graphql query { assignedStarsAndSeries(star: { terms: [ { path: "name", operator: EQ, value: "Bryan Cranston" } ] }) { id star { name } serie { id name categories director { name country } } } } ``` ### 4. Seasons from Series with Directors from a Given Country Find all seasons that belong to series directed by someone from the United States: ```graphql 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 } } } ``` ### 5. Combining Scalar and ObjectType Filters 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](docs/guide/queries.md#collection-frames). Find series named "Breaking Bad" that have at least one season with number 1: ```graphql 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 } } } } ``` ### 6. Complex Nested Queries Get complete information for a specific series: ```graphql query { series(name: { operator: EQ, value: "Breaking Bad" }) { id name categories director { name country } seasons { number year state episodes { number name date } } } } ``` ### 7. Episodes from a Specific Season and Series Find all episodes from Season 1 of Breaking Bad: ```graphql 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 } } } } ``` ### 8. Series by Category Find all crime series: ```graphql query { series(categories: { operator: EQ, value: "Crime" }) { id name categories director { name country } } } ``` ### 9. Search by Partial Episode Name Find episodes containing "Fire" in the name: ```graphql query { episodes(name: { operator: LIKE, value: "Fire" }) { id number name date season { number serie { name } } } } ``` ### 10. Pagination Simfinity.js supports built-in pagination with optional total count: Configure the process-wide maximum once during application startup: ```javascript 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. ```graphql query { series( categories: { operator: EQ, value: "Crime" } pagination: { page: 1, size: 2, count: true } ) { id name categories director { name country } } } ``` #### Pagination Parameters: - **page**: Page number (starts at 1, not 0) - **size**: Number of items per page - **count**: Optional boolean - if `true`, returns total count of matching records #### Getting Total Count: When `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: ```javascript 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](#-plugins-for-count-in-extensions) section for complete examples. #### Example Response: ```json { "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 } } ``` ### 11. Sorting 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](docs/guide/queries.md#ids-in-paths), 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: ```graphql 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 } } } ``` #### Sorting Parameters: - **sort**: Contains sorting configuration - **terms**: Array of sort criteria (allows multiple sort fields) - **field**: The field name to sort by - **order**: Sort order - `ASC` (ascending) or `DESC` (descending) #### Sorting by Nested Fields: You can sort by fields from related/nested objects using dot notation: ```graphql 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 } } } ``` #### Multiple Sort Fields: You can sort by multiple fields with different orders: ```graphql query { series( sort: { terms: [ { field: "director.country", order: ASC }, { field: "name", order: DESC } ] } ) { id name director { name country } } } ``` #### Combining Features: The example above demonstrates combining **filtering**, **pagination**, and **sorting** in a single query - a common pattern for data tables and lists with full functionality. ### 12. Series Released in a Specific Year Range Find series with seasons released between 2010-2015: ```graphql query { seasons(year: { operator: BETWEEN, value: [2010, 2015] }) { id number year serie { name director { name country } } } } ``` ## ๐Ÿ”„ State Machine Example from Series-Sample 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 Machine Configuration State machines require **GraphQL Enum Types** to define states and proper state references: **Step 1: Define the GraphQL Enum Type** ```javascript 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** ```javascript 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** ```javascript 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); ``` ### Season States The Season entity has three states: 1. **SCHEDULED** - Initial state when season is created 2. **ACTIVE** - Season is currently airing 3. **FINISHED** - Season has completed airing ### State Transitions **Available transitions:** - `activate`: SCHEDULED โ†’ ACTIVE - `finalize`: ACTIVE โ†’ FINISHED ### State Machine Mutations Simfinity.js automatically generates state transition mutations: ```graphql # Activate a scheduled season mutation { activateseason(id: "season_id_here") { id number year state serie { name } } } ``` ```graphql # Finalize an active season mutation { finalizeseason(id: "season_id_here") { id number year state serie { name } } } ``` ### State Machine Features **Validation:** - Only valid transitions are allowed - Attempting invalid transitions returns an error - State field is read-only (managed by state machine) **Custom Actions:** - Each transition can execute custom business logic - Actions receive parameters including entity data - Actions can perform side effects (logging, notifications, etc.) **Query by State:** ```graphql query { seasons(state: { operator: EQ, value: ACTIVE }) { id number year state serie { name } } } ``` ### State Machine Best Practices 1. **GraphQL Enum Types**: Always define states as GraphQL enums for type safety 2. **getValue() Method**: Use `enumType.getValue('VALUE')` for state machine configuration 3. **Initial State**: Define clear initial state using enum values 4. **Linear Flows**: Design logical progression (SCHEDULED โ†’ ACTIVE โ†’ FINISHED) 5. **Type Safety**: GraphQL enums provide validation and autocomplete 6. **Actions**: Implement side effects in transition actions 7. **Error Handling**: Handle transition failures gracefully ### Key Implementation Points - **Enum Definition**: States must be defined as `GraphQLEnumType` - **Type Reference**: Use the enum type in your GraphQL object: `state: { type: seasonState }` - **State Machine Values**: Reference enum values with `seasonState.getValue('STATE_NAME')` - **Automatic Validation**: GraphQL validates state values against the enum - **IDE Support**: Enum values provide autocomplete and type checking ### Example Workflow ```graphql # 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" } } ``` ## ๐Ÿ“ฆ Plugins for Count in Extensions 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. ### Envelop Plugin Use `simfinity.plugins.envelopCountPlugin()` to add count to extensions when using Envelop: ```javascript 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 ``` ### Apollo Server Plugin Use `simfinity.plugins.apolloCountPlugin()` to add count to extensions when using Apollo Server: ```javascript 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 }; }, }); ``` ### How to Use 1. **Import the Plugin**: Use `simfinity.plugins.envelopCountPlugin()` or `simfinity.plugins.apolloCountPlugin()` depending on your GraphQL server. 2. **Configure Context**: Ensure that your context includes the count value when executing queries (Simfinity.js automatically sets `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. 3. **Access Count**: The count will be available in the `extensions` field of the GraphQL response. ### Example Response When the plugin is correctly set up, your GraphQL response will include the count in the extensions: ```json { "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. ## ๐Ÿ“– API Reference 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 object **Returns:** - `GraphQLObjectType | null`: The GraphQL type definition, or null if not found **Examples:** ```javascript 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:** - **Type introspection**: Examine type definitions programmatically - **Dynamic schema analysis**: Build tools that analyze your GraphQL schema - **Runtime type checking**: Validate types exist before operations - **Admin interfaces**: Build dynamic forms based on type definitions - **Circular reference resolution**: Prevent import cycles when types reference each other ### Preventing Circular References with `getType` When you have types that reference each other (like User and Group), using `getType` prevents circular import issues: ```javascript 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:** 1. **๐Ÿ”„ No Circular Imports**: Each file can import `getType` without importing other type definitions 2. **โšก Lazy Resolution**: Types are resolved at schema creation time when all types are registered 3. **๐Ÿ›ก๏ธ Type Safety**: Still maintains GraphQL type checking and validation 4. **๐Ÿงน Clean Architecture**: Separates type definitions from type relationships 5. **๐Ÿ“ฆ Better Modularity**: Each type can be in its own file without import dependencies **File 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 ``` ```javascript // 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 object **Returns:** - `MongooseModel`: The associated Mongoose model **Example:** ```javascript 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 object **Returns:** - `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:** ```javascript 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 type - `args` (object): The data to save - `session` (MongooseSession, optional): Caller-owned session with an active transaction, from the same MongoDB client as the registered model - `context` (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 context **Returns:** - `Promise`: The saved object Without 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](docs/guide/mutations.md#which-connection-the-session-uses-on-mongodb). 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:** ```javascript 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. ### `createSchema(includedQueryTypes?, includedMutationTypes?, includedCustomMutations?, options?)` Creates the final GraphQL schema with all connected types. **Parameters:** - `includedQueryTypes` (array, optional): Limit query types to include - `includedMutationTypes` (array, optional): Limit mutation types to include - `includedCustomMutations` (array, optional): Limit custom mutations to include - `options` (object, optional): `{ includedCustomQueries }`, the registered custom query names to include. Omitted or `null` includes all, `[]` includes none, and unregistered names are ignored. A value that is not a plain object, such as an array, and any other key throw `INVALID_SCHEMA_OPTIONS` (500) **Returns:** - `GraphQLSchema`: The complete GraphQL schema **Example:** ```javascript const schema = simfinity.createSchema(); ``` *Built with โค๏ธ by [Simtlix](https://github.com/simtlix)*