
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: start with the quick start, explore the guides, or consult the API reference. The website source is in docs/.
For a complete application, run the Barber examples: independent MongoDB and PostgreSQL backends with one shared Next.js frontend, synthetic demo data, Docker setup, and a dedicated CI workflow. Both backends consume released Simfinity 3.8.0 packages from npm. Their shared HTTP matrix verifies filters, aggregates, scopes and nested mutations; see the API test commands and reference-integrity boundary.
Run the documentation website locally with Node.js 22.12.0+:
npm run docs:install
npm run docs:dev
For builds and hosting, see the website maintainer guide. The website documents the current source; some older examples later in this README retain historical conventions.
Documentation for both databases: The public website now covers MongoDB and PostgreSQL, including shared APIs, relationships, generated FKs, scopes, and MCP. Both adapters are available on npm and released together. Follow the quick starts for installation, or download the runnable starters and verified release archives.
📑 Table of Contents
- Features
- Installation
- PostgreSQL support
- Quick Start
- Core Concepts
- Basic Usage
- Relationships
- Validations
- Optimistic concurrency
- State Machines
- Controllers & Lifecycle Hooks
- Query Scope
- Authorization
- Middlewares
- Advanced Features
- MCP Generation
- Aggregation Queries
- Complete Example
- Resources
- License
- 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
idindex is unused, since the record key is_id;onModelCreated: (model) => model.schema.removeIndex({ id: 1 })removes it from aconnect()type. See the index reference - 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
npm install mongoose@^8.24.2 graphql@^16.11.0 @simtlix/[email protected]
Prerequisites: Simfinity.js requires mongoose and graphql as peer dependencies. Keep them within the ranges above so your application and Simfinity share a single Mongoose and GraphQL instance; npm reports an out-of-range version as a peer conflict. The MCP transports need the optional peer @modelcontextprotocol/sdk@^1.31.0, which is not installed automatically, and graphql-middleware is not a Simfinity dependency.
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:
import { createSQL } from '@simtlix/simfinity-sql';
import { postgresPlugin } from '@simtlix/simfinity-postgres';
const simfinity = createSQL({ plugin: postgresPlugin({ pool, schema: 'app' }) });
Install both packages directly when importing both. Only PostgreSQL is supported initially; see the SQL plugin guide for the contract, capabilities, dependency boundaries and extension requirements.
All publishable libraries live under packages/:
The repository root is a private npm workspace for shared tests, documentation and release tooling. MongoDB retains its existing package name, public API and deep imports such as @simtlix/simfinity-js/src/auth/rules.js. Its legacy src/ modules that re-export public API (src/index.js, src/auth/index.js, src/auth/errors.js, src/auth/expressions.js, src/auth/rules.js, src/plugins.js, src/scalars.js, src/validators.js, src/mcp.js, src/const/*.js, src/errors/*.js, and their extensionless aliases) ship TypeScript declarations with the types of the package root and the matching @simtlix/simfinity-core subpaths; src/mongo/* is internal and undeclared. New code should still import auth, plugins, scalars and validators from the package root. The typed @simtlix/simfinity-core subpaths, such as @simtlix/simfinity-core/auth, need @simtlix/simfinity-core as a direct dependency pinned to the facade's exact version. Run development commands from the root; pack MongoDB with npm pack --workspace @simtlix/simfinity-js or use the release scripts to pack all six libraries. Pushing a release tag (vX.Y.Z) or publishing a GitHub release automatically publishes all six packages with GitHub Actions, attaches verified archives to the release, and deploys the stable documentation. Version changes and merges to master do not publish; the tag must match the aligned package versions and point to source already merged into master. See the release and OIDC setup guide.
The shared development and publication contract applies to contributors and coding agents: when a task includes publication, completion requires the GitHub tag and published release, verified packages in both registries, and the applicable documentation deployment. This rule is tracked in AGENTS.md and the always-applied Cursor workflow rule.
Enum filters resolve member names first, then declared internal values by strict equality, on both backends. For example, with ONE: { value: 'TWO' } and TWO: { value: 'two' }, filter "TWO" selects member TWO. Numeric internal values require numbers, not numeric strings. This applies to scalar lists, embedded/reference leaves and state filters across EQ, NE, LT, LTE, GT, GTE, BTW, IN and NIN. LIKE accepts string fields only. PostgreSQL writes and state guards continue to use internal enum values. Generated MongoDB models store numeric and boolean enum values as numbers and booleans, so MongoDB range filters, sorts and aggregate groupId values follow that type while PostgreSQL uses their text form ("10" before "2"); convert documents written by earlier versions as described in the schema guide.
The shared runtime preserves the v3.1 contract: generated relationships run target middleware/scopes with protected identity and parent filters; nested mutations enforce child middleware and persisted ownership; standalone saveObject() wraps the complete workflow in a transaction. Accepted empty strings are preserved. Both facades expose configureQueryLimits() for bounded pagination and configureMutationLimits() for bounded nested writes.
Choose the backend at application setup. The existing package continues to use MongoDB; PostgreSQL uses createPostgres({ pool, schema }), the same connect(null, Type, ...)/createSchema() signatures, and an awaited initializeDatabase() before serving requests. See the canonical PostgreSQL quick start, detailed storage reference, and compatibility contract before adopting this version.
Both database facades expose the same auth, validators, scalars, and plugins helper objects. PostgreSQL keeps MCP optional; install the database-independent integration and its transport SDK only when needed:
npm install @simtlix/[email protected] @modelcontextprotocol/sdk@^1.31.0
Import generateMCPTools, createMCPServer, or the transport helpers from @simtlix/simfinity-mcp and pass the schema returned by createPostgres().createSchema().
Compatibility tests run the same GraphQL schemas and query corpus against both databases. IDs use UUIDs on PostgreSQL, native model/session APIs differ, and some mappings remain explicitly unsupported, including whole embedded-object uniqueness and whole-object sorting/grouping. Nested embedded scalar-list filters/sorts, ragged array group keys and array aggregate facts now match the supported Mongo query contract. Native embedded writes retain explicit nulls, apply descendant list defaults and minimize empty inline objects; historical Date parameters use UTC. PostgreSQL enforces scalar/list multikey uniqueness within embedded trees through typed owner-key tables, validates JSONB embedded shapes, and checks owned marker/row consistency with deferred database triggers. MongoDB fixes also cover list nullability wrappers, scalar-ID inverse relations, inverse aggregation paths, embedded array replacement after clearing, and isolation of mutation input across transaction retries. Existing API signatures remain unchanged.
🚀 Quick Start
The complete quick start includes MongoDB setup, installation, and working create, read, update, and delete operations. Simfinity uses ES modules and named exports.
npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.24.2 graphql-yoga@^5
Set "type": "module" in your application's package.json, then create server.js:
import { createServer } from 'node:http';
import { GraphQLID, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { createYoga } from 'graphql-yoga';
import mongoose from 'mongoose';
import * as simfinity from '@simtlix/simfinity-js';
const SerieType = new GraphQLObjectType({
name: 'Serie',
fields: {
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
},
});
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/series?replicaSet=rs0&directConnection=true');
simfinity.connect(null, SerieType, 'serie', 'series');
const schema = simfinity.createSchema();
const yoga = createYoga({ schema });
createServer(yoga).listen(4000, '127.0.0.1', () => {
console.log('GraphQL ready at http://127.0.0.1:4000/graphql');
});
Generated mutations require a MongoDB replica set or sharded cluster. Follow the local MongoDB setup, then run node server.js and open http://127.0.0.1:4000/graphql:
mutation {
addserie(input: { name: "The Expanse" }) {
id
name
}
}
query {
series {
id
name
}
}
For a full working application, see the Barber examples, with MongoDB and PostgreSQL backends, a shared frontend, relationships, state machines, controllers, and authorization.
🔧 Core Concepts
Connecting Models
The simfinity.connect() method links your GraphQL types to Simfinity's automatic schema generation:
simfinity.connect(
mongooseModel, // Optional: Custom Mongoose model (null for auto-generation)
graphQLType, // Required: Your GraphQLObjectType
singularEndpointName, // Required: Singular name for mutations (e.g., 'book')
pluralEndpointName, // Required: Plural name for queries (e.g., 'books')
controller, // Optional: Controller with lifecycle hooks
onModelCreated, // Optional: Callback when Mongoose model is created
stateMachine // Optional: State machine configuration
);
Creating Schemas
Generate your complete GraphQL schema with optional type filtering:
const schema = simfinity.createSchema(
includedQueryTypes, // Optional: Array of types to include in queries
includedMutationTypes, // Optional: Array of types to include in mutations
includedCustomMutations, // Optional: Array of custom mutation names to include
{ includedCustomQueries, transaction } // Optional: Options object; custom query names to include and the transaction mutation
);
buildClientDescriptor(schema) now provides immutable JSON metadata for the exact schema returned by createSchema(), including actual operation names, inputs, relations, virtual fields and state transitions. Import it from the optional @simtlix/simfinity-descriptor package, aligned with the facade version. Available since 3.15.0. Mount createClientDescriptorHandler(schema, { authorize: 'public' }) explicitly to serve it; both functions are named exports of the descriptor package. For example:
import { buildClientDescriptor, createClientDescriptorHandler } from '@simtlix/simfinity-descriptor';
const descriptor = buildClientDescriptor(schema);
console.log(descriptor.operations.query);
app.all('/graphql-metadata', simfinity.createClientDescriptorHandler(schema, { authorize: 'public' }));
The 3.x introspection default stays enabled. A new public @simtlix/simfinity-core/introspection subpath exports configureIntrospection({ fieldExtensions: false }), which requires core as a direct dependency pinned to the facade's exact version and must execute before importing the runtime or facades and never removes installed metadata. Current legacy metadata clients need migration before using the opt-out. See the client descriptor reference for bootstrap ordering, protected HTTP access, format and composition limits.
Importing Simfinity initializes its global __Field.extensions introspection field safely whether GraphQL's fields have already been materialized or a schema already exists. Repeated module evaluation with the same GraphQL peer reuses the existing extension field and metadata types. Application field metadata is preserved.
The extension remains shared by every schema using that GraphQL peer; Simfinity's type and middleware registries belong to each runtime instance. A GraphQLObjectType belongs to the first runtime whose schema generates its relation resolvers, or reaches it while one of its non-embedded relation fields has no resolver, such as an unregistered custom mutation result. Registering it in another runtime, reaching it from another runtime's schema, or copying its relation fields with toConfig() after that runtime generated them, throws TYPE_BOUND_TO_OTHER_RUNTIME. Give each runtime its own type objects, for example from a factory function. Types without non-embedded relation fields, and types whose relations all have your own resolvers, can be shared; see types belong to one runtime. Schemas constructed after import include FieldExtensionsType and RelationType in their type maps, or their fallback names SimfinityFieldExtensionsType and SimfinityRelationType once an application type with one of those names was in the process's first Simfinity schema; see what gets generated. Earlier schemas keep their original type maps: ordinary operations and direct metadata selections work, but named fragments on the new metadata types require a schema constructed after import.
Schema-cloning tools remain unsupported. In a process that has imported Simfinity, buildClientSchema() on a post-import schema's introspection result can also fail with duplicate metadata type names. Use Envelop plugins and in-place resolver wrapping; see the introspection metadata reference.
MongoDB Reference Integrity
Opt into reference checks when creating the MongoDB adapter. The default remains 'off', preserving existing applications:
import { createMongoAdapter, createRuntime } from '@simtlix/simfinity-js';
const adapter = createMongoAdapter({ referentialIntegrity: 'transactional' });
const simfinity = createRuntime(adapter);
await mongoose.connect(process.env.MONGODB_URI);
simfinity.connect(null, SerieType, 'serie', 'series');
const schema = simfinity.createSchema();
await adapter.initialize();
Register the complete type graph and await initialization before serving HTTP or MCP. Initialization audits existing references and verifies a transaction-capable MongoDB deployment. Protected writes reject missing targets and restrict deletion of referenced documents, including references within embedded lists, inferred/private inverse keys and explicit many-to-many links. Target-document writes coordinate concurrent creation/deletion. Failures return REFERENCE_CONSTRAINT_VIOLATION (409) and roll back the mutation.
The mode is fixed at startup. All writers must share the same graph/mode; raw Mongoose/driver writes bypass this protection. PostgreSQL retains native database FKs. See MongoDB reference integrity for snapshot/majority sessions, reserved lock metadata, contention and existing-data migration.
Global Configuration
// Prevent automatic MongoDB collection creation (useful for testing)
simfinity.preventCreatingCollection(true);
// Cap the nested added/updated/deleted entries of one generated mutation (process-wide; unlimited by default)
simfinity.configureMutationLimits({ maxNestedOperations: 100 });
A mutation over the limit fails with NESTED_OPERATIONS_EXCEEDED (400) before its transaction starts. Call configureMutationLimits() without arguments to remove the limit. Unknown options, such as a misspelled maxNestedOperation, throw INVALID_MUTATION_LIMITS (400) and keep the current limit.
Since 3.16.0, updateObject() and transitionObject() apply maxNestedOperations to their input like the generated mutations, and configureMutationLimits({ maxNestedOperations, maxTransactionOperations }) also caps the operations of one transaction mutation (a positive safe integer; 100 by default). Each call replaces both values, so an omitted option takes its default again, and the unknown-option message reads Unknown mutation limit option <key>; use maxNestedOperations or maxTransactionOperations. Inside a transaction mutation the nested limit is one joint budget for every nested entry that runs on an attempt's session, including those that hooks and helper calls add.
📋 Basic Usage
Automatic Query Generation
Simfinity automatically generates queries for each connected type:
// For a BookType, you get:
// - book(id: ID): Book - Get single book by ID
// - books(...filters): [Book] - Get filtered list of books
Automatic Mutation Generation
Simfinity automatically generates mutations for each connected type:
// For a BookType, you get:
// - addBook(input: BookInput): Book
// - updateBook(input: BookInputForUpdate): Book
// - deleteBook(id: ID): Book
A type without writable fields gets no add mutation, and, when its id is missing or readOnly, no update mutation or state machine actions either; Simfinity logs a Configuration issue warning. See what gets generated.
Creation inputs retain required scalar, enum, embedded-object, and list fields. Update inputs remove the outer non-null wrapper so fields can be omitted; the entity id remains required for GraphQLID and GraphQLID!. Other ID fields remain optional on update. List item nullability is preserved:
The same wrapper handling applies to enum lists, supported custom scalar lists, and embedded lists (using their nested input types). Referenced collections keep the added / updated / deleted input object: a required collection requires that object on create, and non-null object items make added and updated items non-null. Nullable collection-operation items are ignored. Use deleted to delete children. Collections can form trees and cycles across types, and nested inputs follow them at any depth; deleted is [ID] for unprotected children whatever the item nullability, and null IDs are ignored. A protected child instead uses [RecordVersionRef] with { id, version } entries, and its updated entries require version, even through an unprotected parent. See generated collection input names.
Empty strings, false, 0, and empty arrays are persisted after validators accept them. Omitting an update field leaves its value unchanged. Explicit null clears a nullable scalar, embedded field, or single-object reference; references clear the actual stored connectionField, or the GraphQL field name when no override is configured. Multiple nullable fields can be cleared in one update. An explicit null for an originally non-null field leaves its stored value unchanged; use an update validator to reject that input if needed. Inside an embedded object patch, an explicit null clears a nullable member the same way, but the member is stored as null (a list member as []) instead of being removed; see update selected fields.
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. Client filter, sort and aggregation paths cannot name a field with extensions.queryable: false or an application-defined resolve unless it sets extensions.queryable: true; such paths fail with FORBIDDEN_FILTER_PATH (403). This covers masking resolvers, relationship fields with a manual resolver, and a custom id resolver. When upgrading, remove manual relationship resolvers that only load the related record, because Simfinity generates them, or set queryable: true on fields whose resolver returns the stored value. Paths through scoped relationships apply the target's scope, as described under Query Scope, and the authorization plugin checks field rules for every path segment. Root and relationship id paths refer to the stored _id. Inside an embedded object, id is the declared id member, except for entity types embedded as lists and supplied subdocument schemas without an id path, which use the subdocument _id; see IDs in paths. Comparisons use the connected Mongoose model's schema, so supplied string or numeric ID models retain their identifier representation.
On both bundled adapters, collection filter paths are correlated ANY frames and return each record once in lists, counts and pages. One group shares one matching child per collection prefix; separate groups may match different children. Explicit ANY/ALL/NONE groups and relation-filter quantifier use element-relative paths; without elements they hold false/true/true. Client elements use the target's find scope, while server-added conditions read stored data. A sort through a collection gives INVALID_FILTER_PATH before scopes, including count mode. Aggregate prefixes used by groupId or facts retain joins; filter-only prefixes become frames. See collection frames and the 3.14.0 upgrade notes.
Filter values must use the field's JSON scalar type. IN and NIN require flat lists, BTW requires exactly two non-null bounds, and LIKE and the other text operators require a string value on a String field or list. IIN requires a list of strings without U+0000 on a String field or list, and IIN [] matches nothing. CONTAINS_ALL requires a non-empty flat list of non-null scalars without objects, so [] is rejected, and applies to a scalar list or a field read through an embedded list, where every value must be among the values of all items; see filters on lists. Literal objects, nested lists, null list elements, invalid operators, unknown paths, and malformed groups are rejected with a structured 400 error instead of being ignored. Explicit null remains supported by EQ and NE. List items may be operation variables, as in value: [$from, $to]. Declare them as QLValue (or QLValue!): GraphQL rejects other declared types, such as Int or String, in that position. A list item whose variable the request leaves unset reads as null, as GraphQL reads list arguments, so it is rejected like an explicit null element. Enum literals such as value: ACTIVE or value: [ACTIVE, INACTIVE] are read as the enum names, like "ACTIVE". Enum names or declared internal enum values are converted to the stored representation; state-machine state filters preserve stored state names. Date filters convert valid date values without mutating the query input. Validated string scalars support partial text searches, and range filters use their base scalar type rather than the field's create/update validation constraints.
Query with powerful filtering options:
query {
books(
title: { operator: LIKE, value: "Galaxy" }
author: { operator: EQ, value: "Douglas Adams" }
pagination: { page: 1, size: 10, count: true }
sort: { terms: [{ field: "title", order: ASC }] }
) {
id
title
author
}
}
Available Operators
EQ- EqualNE- Not equalGT- Greater thanLT- Less thanGTE- Greater than or equalLTE- Less than or equalIN- In arrayNIN- Not in arrayIIN- In array, ignoring case (String fields and lists)CONTAINS_ALL- A list holds every value of the arrayBTW- Between two valuesLIKE- Literal substring, respecting caseILIKE- Literal substring, ignoring caseSTARTS_WITH,ISTARTS_WITH- Literal prefix, respecting or ignoring caseENDS_WITH,IENDS_WITH- Literal suffix, respecting or ignoring caseIEQ- Equal, ignoring caseIS_EMPTY,IS_NOT_EMPTY- The field holds no value, or holds one; they take no 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 for the rules, the differences and the PostgreSQL indexes that serve them, which caseFoldExpression() writes. IIN folds case as the other case-insensitive operators do: it matches where IEQ matches one of its values. A custom database adapter or SQL plugin receives them, and IIN and CONTAINS_ALL, only after it declares them in queryFeatures.
IS_EMPTY holds for a String that is missing, null or "", any other scalar or enum that is missing or null, a list that is missing, null or [] ([null] is not empty), and an embedded object that is missing, null or whose stored members are all empty; IS_NOT_EMPTY is its exact complement. Omit value or pass null. On an embedded object or list, write them as a term or group condition without path, such as address: { terms: [{ operator: IS_EMPTY }] } or { field: "address", operator: IS_EMPTY }. On a list, EQ, NE, IN and NIN compare the elements, so EQ null also matches [null] and never []. They also test a reference or a collection, such as season: { terms: [{ operator: IS_EMPTY }] }, and fields reached through references, such as season.name: for the client's conditions, a record that the target type's find scope hides reads as a missing one, while conditions that middleware and scopes add read stored data. A custom database adapter or SQL plugin answers these only when it declares referenceEmptiness in queryFeatures, and otherwise rejects them with UNSUPPORTED_QUERY; see references and collections. An emptiness path inside an embedded list remains invalid. Collection shorthand excludes leaf emptiness (INVALID_FILTER_PATH); use an explicit quantified group to test a collection element. See empty values for the rules, the rejected forms and their codes, and the generated writes that the member rule reads as empty. A custom database adapter or SQL plugin receives them only after it declares them in queryFeatures too.
Field comparisons: use valuePath instead of value, for example products(stock: { operator: LTE, valuePath: "reorderLevel" }) { id } on a schema declaring those fields. It accepts compatible scalar/enum equality, numeric/String/matching-date ordering and literal String LIKE/STARTS_WITH/ENDS_WITH; scalar-list EQ means membership and NE is its complement. Missing/null compare equally, and null ordering/text is false. String ordering follows MongoDB's effective operation collation or PostgreSQL's C collation; exact EQ/NE and supported text predicates stay independent of collation. Both operands obey read permissions and target scopes. Relation terms resolve the right path from the related object; explicit quantified groups from their element. Collection shorthand compares the same child and can read its original containing record, while unrelated right collections remain invalid. Version 3.18.0 additionally supports embedded object list frames and structural $parent with queryFeatures.elementFrames: true; see embedded containers. The bundled adapters declare queryFeatures.valuePath: true; custom adapters must declare it with referenceEmptiness: true. value and valuePath are mutually exclusive, including value: null. See the full syntax, null/list rules and limits.
In aggregates, field comparisons preserve the row shared with collection facts; deeper filter-only collections do not multiply those facts. Existing target-scope restrictions on aggregate collection joins still apply. See the Barber aggregate example.
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:
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:
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
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:
query {
books(
OR: [
{ conditions: [{ field: "author", path: "name", operator: LIKE, value: "Adams" }] }
{ conditions: [{ field: "author", path: "name", operator: LIKE, value: "Pratchett" }] }
]
) {
id
title
author { name }
}
}
As an equivalent shorthand, you can pass the full dotted path in field and omit path:
query {
books(
OR: [
{ conditions: [{ field: "author.name", operator: LIKE, value: "Adams" }] }
{ conditions: [{ field: "author.name", operator: LIKE, value: "Pratchett" }] }
]
) {
id
title
author { name }
}
}
Both forms produce the same query. Multi-segment paths work too — e.g. field: "author.country.code" is equivalent to field: "author", path: "country.code". Supplying both a dotted field and a separate path is rejected as ambiguous.
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:
query {
books(
rating: { operator: GTE, value: 7.0 }
author: { terms: [{ path: "country", operator: EQ, value: "UK" }] }
OR: [
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
]
) {
id
title
rating
category
author { name country }
}
}
This translates to: rating >= 7.0 AND author.country = "UK" AND (category = "Sci-Fi" OR category = "Fantasy"). The flat field filters (rating, author) use the existing syntax while the OR group uses the new QLFilterGroup syntax.
You can also combine flat filters with explicit AND groups for more complex logic:
query {
books(
rating: { operator: GTE, value: 5.0 }
AND: [
{
OR: [
{ conditions: [{ field: "category", operator: EQ, value: "Sci-Fi" }] }
{ conditions: [{ field: "category", operator: EQ, value: "Fantasy" }] }
]
}
{
OR: [
{ conditions: [{ field: "author", path: "country", operator: EQ, value: "UK" }] }
{ conditions: [{ field: "author", path: "country", operator: EQ, value: "US" }] }
]
}
]
) {
id
title
}
}
This translates to: rating >= 5.0 AND (category = "Sci-Fi" OR category = "Fantasy") AND (author.country = "UK" OR author.country = "US").
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:
query {
series {
seasons(
OR: [
{ conditions: [{ field: "year", operator: EQ, value: 2020 }] }
{ conditions: [{ field: "year", operator: EQ, value: 2021 }] }
]
) {
number
year
}
}
}
You can mix flat collection filters with AND/OR:
query {
series {
seasons(
number: { operator: GT, value: 1 }
OR: [
{ conditions: [{ field: "year", operator: EQ, value: 2020 }] }
{
AND: [
{ conditions: [{ field: "year", operator: GTE, value: 2022 }] }
{ conditions: [
# Equivalent to: { field: "episodes", path: "name", ... }
{ field: "episodes.name", operator: LIKE, value: "Final" }
] }
]
}
]
) {
number
year
episodes { name }
}
}
}
This translates to: number > 1 AND (year = 2020 OR (year >= 2022 AND episodes.name LIKE "Final")).
Filter Types Reference
fieldidentifies 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")pathis required for object/relationship fields whenfieldis just the top-level name (e.g.,"name","country.name"). Omitpathif you used the dotted-fieldshorthand. Supplying both is an error.- Multiple
conditionsin 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:
query {
series {
seasons(number: { operator: EQ, value: 1 }) {
number
id
year
}
}
}
Advanced Collection Filtering
You can use complex filtering with nested object properties:
query {
series {
seasons(
year: { operator: GTE, value: 2020 }
episodes: {
terms: [
{
path: "name",
operator: LIKE,
value: "Pilot"
}
]
}
) {
number
year
episodes {
name
date
}
}
}
}
Collection Filtering with Multiple Conditions
Combine multiple filter conditions for collection fields:
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:
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:
query {
series {
seasons(
categories: { operator: IN, value: ["Drama", "Crime"] }
) {
number
categories
}
}
}
Note: Collection field filtering uses the exact same format as main query filtering, ensuring consistency across your GraphQL API. All available operators (EQ, NE, GT, LT, GTE, LTE, IN, NIN, IIN, CONTAINS_ALL, BTW, LIKE, ILIKE, STARTS_WITH, ISTARTS_WITH, ENDS_WITH, IENDS_WITH, IEQ, IS_EMPTY, IS_NOT_EMPTY) work with collection fields.
🔗 Relationships
Defining Relationships
Use the extensions.relation field to define relationships between types:
const AuthorType = new GraphQLObjectType({
name: 'Author',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
books: {
type: new GraphQLList(BookType),
extensions: {
relation: {
connectionField: 'author',
displayField: 'title'
},
},
// resolve method automatically generated! 🎉
},
}),
});
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
// resolve method automatically generated! 🎉
},
}),
});
Relationship Configuration
connectionField: (Required for referenced collections) The child field linking back to the parent. A referenced collection without it failscreateSchema()withINVALID_MODEL(400), unless, in default MongoDB mode, the field has its own resolver or isreadOnly; such a field builds, with a warning unless it is both, and rejects nested writes through it. For a single-object reference, this optionally overrides the stored ObjectId field; when omitted, model generation, writes, clears, and resolvers use the GraphQL field name.displayField: (Optional) Field to use for display in UI componentsembedded: (Optional) Whether the relation is embedded (default: false)
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)
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)
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 orconnectionField - Collection Relationships: Automatically generates
find()resolvers using theconnectionFieldto 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
findorfindOnepre/post hook keep individualfindOnereads, 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 withoutonDeleteand before an update that merges no embedded value. See the relationship batching contract. - 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 increatePostgres()orconfigure(), with one statement per 100 parents in a transaction that turns JIT off for that transaction only, and MongoDB increateMongoAdapter(), 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. - 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
// 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:
simfinity.addNoEndpointType(TypeName);
When to use addNoEndpointType() vs connect():
Perfect Example: TV Series with Embedded Director
From the series-sample project:
// Director type - Used only as embedded data, no direct API access needed
const directorType = new GraphQLObjectType({
name: 'director',
fields: () => ({
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
country: { type: GraphQLString }
})
});
// Add to schema WITHOUT creating endpoints
simfinity.addNoEndpointType(directorType);
// Serie type - Has its own endpoints and embeds director data
const serieType = new GraphQLObjectType({
name: 'serie',
fields: () => ({
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
categories: { type: new GraphQLList(GraphQLString) },
director: {
type: new GraphQLNonNull(directorType),
extensions: {
relation: {
embedded: true, // Director data stored within serie document
displayField: 'name'
}
}
}
})
});
// Create full CRUD endpoints for series
simfinity.connect(null, serieType, 'serie', 'series');
Result:
- ✅
addserie,updateserie,deleteseriemutations available - ✅
serie,seriesqueries available - ❌ No
adddirector,director,directorsendpoints (director is embedded)
Usage:
mutation {
addserie(input: {
name: "Breaking Bad"
categories: ["crime", "drama", "thriller"]
director: {
name: "Vince Gilligan"
country: "United States"
}
}) {
id
name
director {
name
country
}
}
}
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):
// Stores author ID in the book document
author: {
type: AuthorType,
extensions: {
relation: {
// connectionField not needed for single object relationships
embedded: false // This is the default
}
}
}
Embedded Relationships:
// Stores the full publisher object in the book document
publisher: {
type: PublisherType,
extensions: {
relation: {
embedded: true
}
}
}
Embedded types cannot contain each other, directly or through other embedded types: createSchema() throws INVALID_MODEL (Embedded cycle at …), even when a field of the cycle is readOnly, whenever Simfinity generates a model whose embedded fields reach the cycle, and always with referentialIntegrity: 'transactional'. In the default mode, a cycle that only supplied models reach is not checked, as before. Use a reference for recursive structures. An optional embedded object that holds no data reads as null when a required member would read as null, not counting id, readOnly, interface and union members, members with your own resolver and referenced collections; see MongoDB embedded objects in hydrated documents.
Querying Relationships
Query nested relationships with dot notation:
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:
mutation {
addBook(input: {
title: "New Book"
author: {
id: "existing_author_id"
}
}) {
id
title
author {
name
}
}
}
Create embedded objects:
mutation {
addBook(input: {
title: "New Book"
publisher: {
name: "Penguin Books"
location: "London"
}
}) {
id
title
publisher {
name
location
}
}
}
Collection Fields
Work with arrays of related objects:
mutation {
updateBook(input: {
id: "book_id"
reviews: {
added: [
{ rating: 5, comment: "Amazing!" }
{ rating: 4, comment: "Good read" }
]
updated: [
{ id: "review_id", rating: 3 }
]
deleted: ["review_id_to_delete"]
}
}) {
id
title
reviews {
rating
comment
}
}
}
For non-embedded collections, each added, updated, or deleted child runs the target type's global middleware with the request context (for a programmatic saveObject() call, the context passed to it, possibly undefined). The operation and argument shapes match root mutations: save/update receive { input }, and delete receives { id } ({ id, version } for protected root and nested deletes). Updated and deleted children must already belong to the current parent in the transaction; a missing child raises NOT_VALID_ID, and a different parent raises ForbiddenError (FORBIDDEN). Ownership compares the stored link with the parent's stored identifier, so any accepted spelling of the parent ID works. Nested updates cannot move a child between parents. A rejection aborts the parent mutation and its child changes. To cap how many added, updated and deleted entries one mutation may carry across all nesting levels, call simfinity.configureMutationLimits({ maxNestedOperations }) at startup; see limit nested collection operations.
✅ Validations
Declarative Validation Helpers
Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
Using Validators
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 withundefinedornull)validators.maxLength(name, max)- Validates maximum string lengthvalidators.pattern(name, regex, message)- Validates against a regex pattern (each value is tested from its first character, whatever theg/yflags; a pattern that is not aRegExpor a string throwsTypeError)validators.email()- Validates email formatvalidators.url()- Validates URL format
Number Validators:
validators.numberRange(name, min, max)- Validates number range (omit a bound withundefinedornull)validators.positive(name)- Ensures number is positive
Array Validators:
validators.arrayLength(name, maxItems, itemValidator)- Validates array length and optionally each item;itemValidatoris a validator, a helper result such asvalidators.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 slashedMM/DD/YYYYstrings 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 asdateFormatwithout a format
Validator Features
- Automatic Operation Handling: Validators work for both
CREATE(save) andUPDATEoperations - 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
SimfinityErrorwith appropriate messages
Example: Multiple Validators
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:
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:
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:
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:
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 withundefinedornull)scalars.createBoundedIntScalar(name, min, max)- Integer with range validation (omit a bound withundefinedornull)scalars.createBoundedFloatScalar(name, min, max)- Float with range validation (omit a bound withundefinedornull)scalars.createPatternStringScalar(name, pattern, message)- String with regex pattern validation (same pattern rules asvalidators.pattern)
Creating Custom Scalars Manually
You can also create custom scalars using createValidatedScalar directly:
const { GraphQLString, GraphQLInt } = require('graphql');
const { createValidatedScalar } = require('@simtlix/simfinity-js');
// Email scalar with validation (generates type name: Email_String)
const EmailScalar = createValidatedScalar(
'Email',
'A valid email address',
GraphQLString,
(value) => {
// The lookahead keeps matching linear on hostile input such as 'a@' + '.'.repeat(n) + ' '.
const emailRegex = /^[^\s@]+@(?=[^\s@]*$)[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error('Invalid email format');
}
}
);
// Positive integer scalar (generates type name: PositiveInt_Int)
const PositiveIntScalar = createValidatedScalar(
'PositiveInt',
'A positive integer',
GraphQLInt,
(value) => {
if (value <= 0) {
throw new Error('Value must be positive');
}
}
);
// Use in your types
const UserType = new GraphQLObjectType({
name: 'User',
fields: () => ({
id: { type: GraphQLID },
email: { type: EmailScalar }, // Type name: Email_String
age: { type: PositiveIntScalar }, // Type name: PositiveInt_Int
}),
});
The validation callback receives the base scalar's parsed value for variables and inline literals (for example, a Date from a DateTime base), and the resolver's value before serialization for output. Inline literal kinds follow the root of the chain of validated scalars, so a scalar built on PositiveIntScalar accepts integer literals, and Float-rooted scalars accept both 4 and 4.0. Custom root scalars decide their own literals, even with a hand-set baseScalarType storage hint. See validated scalars.
Custom Error Classes
Create domain-specific error classes:
const { SimfinityError } = require('@simtlix/simfinity-js');
// Business logic error
class BusinessError extends SimfinityError {
constructor(message) {
super(message, 'BUSINESS_ERROR', 400);
}
}
// Authorization error
class AuthorizationError extends SimfinityError {
constructor(message) {
super(message, 'UNAUTHORIZED', 401);
}
}
// Not found error
class NotFoundError extends SimfinityError {
constructor(message) {
super(message, 'NOT_FOUND', 404);
}
}
To normalize errors in your GraphQL server, pass simfinity.buildErrorFormatter(callback) to its error-formatting option. For an error raised while resolving a field, it classifies the raised value, such as the one a resolver threw; other errors are classified as they are:
- A
SimfinityErrorkeeps its code and status. - A request
GraphQLError, such as a syntax, validation or variable error, keeps its message and extensions, including thehttpstatus Yoga adds. So does aGraphQLErrorraised while resolving a field, but only when it has its own stringextensions.codeor aSimfinityErrorcause. Its code is its ownextensions.code, orBAD_REQUEST, and its status is itsextensions.status, or 500 forINTERNAL_SERVER_ERRORand 400 otherwise; aSimfinityErrorcause, such as one thrown by a custom scalar, supplies them instead. Input rejected by Simfinity's validated scalars, such as an invalidEmailScalarvalue, is thereforeBAD_REQUEST(400), and the hiddenoriginalErrorof an applicationGraphQLErroris never exposed. A code the server already set is kept, such as Yoga'sGRAPHQL_PARSE_FAILEDor Apollo Server'sBAD_USER_INPUT. - Any other
GraphQLErrorraised 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 anInternalServerError(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
Errorbecomes anInternalServerError(INTERNAL_SERVER_ERROR, status 500) that keeps the message and the error as its cause; a non-Error value becomesUnexpected error valuewith no cause. Under Yoga, whose executor turns a thrown non-Error value into anErrorwith the value's text, that text becomes theInternalServerErrormessage instead.
A GraphQL error without path is treated as a request error, including a scalar literal validation error that wraps another error with the same message. For subscriptions, mark unexpected source-stream failures with InternalServerError before the server wraps them: those wrappers can be indistinguishable from request errors. For a GraphQL error without a path, the formatter keeps an explicit internal cause for masking.
The callback receives the classified error and can return a replacement. To hide unexpected messages, branch on error instanceof simfinity.InternalServerError. Since 3.16.0, an error of the transaction mutation carries the position of its operation, which a callback that returns a new InternalServerError drops unless it copies operationIndex and operation; see errors of the transaction mutation. The formatter returns a GraphQLError whose originalError is the classified error. See buildErrorFormatter, server integration and server differences.
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 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
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
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
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
simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);
5. Use State Machine Mutations
The state machine automatically generates mutations for each action:
mutation {
process_order(input: {
id: "order_id"
}) {
id
state
customer
}
}
Important Notes:
- The
statefield is automatically read-only and managed by the state machine - State transitions are only allowed based on the defined actions
- Business logic in the
actionfunction is executed during transitions - Invalid transitions throw errors automatically
- Since 3.16.0, server code can run an action without GraphQL with
simfinity.transitionObject('Order', 'process', { id }, session, context); see Programmatic Data Access
🎛️ Controllers & Lifecycle Hooks
Controllers provide fine-grained control over operations with lifecycle hooks:
const bookController = {
onSaving: async (doc, args, session, context) => {
// Before saving - doc is a Mongoose document
if (!doc.title || doc.title.trim().length === 0) {
throw new Error('Book title cannot be empty');
}
// Access user from context to set owner
if (context && context.user) {
doc.owner = context.user.id;
}
console.log(`Creating book: ${doc.title}`);
},
onSaved: async (doc, args, session, context) => {
// After saving - doc is a plain object
console.log(`Book saved: ${doc._id}`);
// Use context.user for transactional audit/outbox records on this session
},
onUpdating: async (id, doc, session, context) => {
// Before updating - doc contains only changed fields
// Validate user has permission to update
if (context && context.user && context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Only admins can update books', 'FORBIDDEN', 403);
}
console.log(`Updating book ${id}`);
},
onUpdated: async (doc, session, context) => {
// After updating - doc is the updated document
console.log(`Book updated: ${doc.title}`);
},
onDelete: async (doc, session, context) => {
// Before deleting - doc is the document to be deleted
// Validate user has permission to delete
if (context && context.user && context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Only admins can delete books', 'FORBIDDEN', 403);
}
console.log(`Deleting book: ${doc.title}`);
}
};
// Connect with controller
simfinity.connect(null, BookType, 'book', 'books', bookController);
Hook Parameters
onSaving(doc, args, session, context):
doc: Mongoose Document instance (not yet saved)args: Raw GraphQL mutation inputsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)
onSaved(doc, args, session, context):
doc: Plain object of saved documentargs: Raw GraphQL mutation inputsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)
onUpdating(id, doc, session, context):
id: Document ID being updateddoc: Plain object with only changed fieldssession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)
onUpdated(doc, session, context):
doc: Full updated Mongoose document, after the parent update and nested collection writes have completedsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)
onDelete(doc, session, context):
doc: Plain object of document to be deletedsession: Mongoose session for transactioncontext: GraphQL context object (includes request info, user data, etc.)
The parent update executes before nested child operations. A malformed ID throws NOT_VALID_ID (400) before anything is written. For a type without embedded fields, onUpdating runs before that check and receives the raw ID, so a hook that looks the record up with findById() must handle a malformed value, which makes Mongoose throw a CastError. An update with no matching parent throws NOT_VALID_ID (404), without running child operations or onUpdated.
Lifecycle hooks, including onSaved and onUpdated, run before the transaction commits. They can run again after a transient transaction failure, with up to five retries after the first attempt; each retry waits a short random delay first. A write conflict that persists after the last retry fails with TRANSACTION_RETRY_EXCEEDED (409), with the driver error as error.cause. Keep additional database writes on the supplied session. For email or webhooks, write an outbox record in that transaction and deliver it after commit; external calls inside hooks cannot be rolled back. A