Simfinity.js
A powerful Node.js framework that automatically generates GraphQL schemas from your data models, bringing all the power and flexibility of MongoDB query language to GraphQL interfaces.
Table of Contents
- Features
- Installation
- Quick Start
- Core Concepts
- Basic Usage
- Relationships
- Validations
- 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 Integration: Seamless translation between GraphQL and MongoDB
- Powerful Querying: Any query that can be executed in MongoDB can be executed in GraphQL
- 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: Automatically creates MongoDB indexes for all ObjectId fields, including nested embedded objects and relationship fields
- 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 graphql @simtlix/simfinity-js
Prerequisites: Simfinity.js requires mongoose and graphql as peer dependencies.
Quick Start
1. Basic Setup
const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const mongoose = require('mongoose');
const simfinity = require('@simtlix/simfinity-js');
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/bookstore', {
useNewUrlParser: true,
useUnifiedTopology: true,
});
const app = express();
2. Define Your GraphQL Type
const { GraphQLObjectType, GraphQLString, GraphQLNonNull, GraphQLID } = require('graphql');
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: { type: GraphQLString },
}),
});
3. Connect to Simfinity
// Connect the type to Simfinity
simfinity.connect(null, BookType, 'book', 'books');
// Create the GraphQL schema
const schema = simfinity.createSchema();
4. Setup GraphQL Endpoint
app.use('/graphql', graphqlHTTP({
schema,
graphiql: true,
formatError: simfinity.buildErrorFormatter((err) => {
console.log(err);
})
}));
app.listen(4000, () => {
console.log('Server is running on port 4000');
});
5. Try It Out
Open http://localhost:4000/graphql and try these queries:
Create a book:
mutation {
addBook(input: {
title: "The Hitchhiker's Guide to the Galaxy"
author: "Douglas Adams"
}) {
id
title
author
}
}
List all books:
query {
books {
id
title
author
}
}
For a full working application, see the Series Sample Project -- a complete TV series microservice with types, 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 mutations to include
);
Global Configuration
// Prevent automatic MongoDB collection creation (useful for testing)
simfinity.preventCreatingCollection(true);
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
Filtering and Querying
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 equalLIKE- Pattern matchingIN- In arrayNIN- Not in arrayBTW- Between two values
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
| 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 |
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, LIKE, IN, NIN, BTW) 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 collections) The field storing the related object's ID - only needed for one-to-many relationships (GraphQLList). For single object relationships, the field name is automatically inferred from 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
- Backwards Compatible: Existing manual resolve methods are preserved and not overwritten
- 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():
| 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 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
}
}
}
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
}
}
}
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)validators.maxLength(name, max)- Validates maximum string lengthvalidators.pattern(name, regex, message)- Validates against a regex patternvalidators.email()- Validates email formatvalidators.url()- Validates URL format
Number Validators:
validators.numberRange(name, min, max)- Validates number rangevalidators.positive(name)- Ensures number is positive
Array Validators:
validators.arrayLength(name, maxItems, itemValidator)- Validates array length and optionally each item
Date Validators:
validators.dateFormat(name, format)- Validates date formatvalidators.futureDate(name)- Ensures date is in the future
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 boundsscalars.createBoundedIntScalar(name, min, max)- Integer with range validationscalars.createBoundedFloatScalar(name, min, max)- Float with range validationscalars.createPatternStringScalar(name, pattern, message)- String with regex pattern validation
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) => {
const emailRegex = /^[^\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
}),
});
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);
}
}
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 }
})
});
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
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}`);
// Can access context.user for post-save operations like notifications
},
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 documentsession: 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.)
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
const documentController = {
onSaving: async (doc, args, session, context) => {
// Automatically set the owner to the current user
if (context && context.user) {
doc.owner = context.user.id;
}
}
};
Example: Role-Based Authorization
const adminOnlyController = {
onUpdating: async (id, doc, session, context) => {
if (!context || !context.user || context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
}
},
onDelete: async (doc, session, context) => {
if (!context || !context.user || context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
}
}
};
Note: When using saveObject programmatically (outside of GraphQL), the context parameter is optional and may be undefined. Always check for context existence before accessing its properties.
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:
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.owner = {
terms: [
{
path: 'id',
operator: 'EQ',
value: context.user.id
}
]
};
},
aggregate: async ({ type, args, operation, context }) => {
// Apply same scope to aggregate queries
args.owner = {
terms: [
{
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:
const DocumentType = new GraphQLObjectType({
name: 'Document',
extensions: {
scope: {
find: async ({ type, args, operation, context }) => {
// Only show documents owned by the current user
args.owner = {
terms: [
{
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:
const OrderType = new GraphQLObjectType({
name: 'Order',
extensions: {
scope: {
aggregate: async ({ type, args, operation, context }) => {
// Only aggregate orders for the current user's organization
args.organization = {
terms: [
{
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:
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:
- Create a query that includes both the id filter and the owner scope filter
- Only return the document if it matches both conditions
- Return
nullif the document exists but doesn't match the scope
Scope Function Parameters
Scope functions receive the same parameters as middleware for consistency:
{
type, // Type information (model, gqltype, controller, etc.)
args, // GraphQL arguments passed to the operation (modify this object)
operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
context // GraphQL context object (includes request info, user data, etc.)
}
Filter Structure
When modifying args in scope functions, use the appropriate filter structure:
For scalar fields:
args.fieldName = {
operator: 'EQ',
value: 'someValue'
};
For object/relation fields (QLTypeFilterExpression):
args.relationField = {
terms: [
{
path: 'fieldName',
operator: 'EQ',
value: 'someValue'
}
]
};
Complete Example
Here's a complete example showing scope for all query operations:
const EpisodeType = new GraphQLObjectType({
name: 'episode',
extensions: {
validations: {
save: [validateEpisodeFields],
update: [validateEpisodeBusinessRules]
},
scope: {
find: async ({ type, args, operation, context }) => {
// Only show episodes from seasons the user has access to
args.season = {
terms: [
{
path: 'owner.id',
operator: 'EQ',
value: context.user.id
}
]
};
},
aggregate: async ({ type, args, operation, context }) => {
// Apply same scope to aggregations
args.season = {
terms: [
{
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
argsobject directly - Filter Structure: Use the correct filter structure (
QLFilterfor scalars,QLTypeFilterExpressionfor relations) - All Query Operations: Scope applies to
find,aggregate, andget_by_idoperations - 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
const { auth } = require('@simtlix/simfinity-js');
const { createYoga } = require('graphql-yoga');
const { createAuthPlugin, requireAuth, requireRole } = auth;
// Define your permission schema
// Query/Mutation names match the ones generated by simfinity.connect()
const permissions = {
Query: {
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:
const permissions = {
// Operation types (Query, Mutation, Subscription)
Query: {
fieldName: ruleOrRules,
},
// Object types
TypeName: {
'*': wildcardRule, // Applies to all fields unless overridden
fieldName: specificRule, // Overrides wildcard for this field
},
};
Resolution Order:
- Check exact field rule:
permissions[TypeName][fieldName] - Fallback to wildcard:
permissions[TypeName]['*'] - Apply default policy (ALLOW or DENY)
Rule Types:
- Function:
(parent, args, ctx, info) => boolean | void | Promise<boolean | void> - Array of functions: All rules must pass (AND logic)
- Policy expression: JSON AST object (see below)
Rule Semantics:
return trueorreturn void→ allowreturn false→ denythrow Error→ deny with error
Rule Helpers
Simfinity.js provides reusable rule builders:
const { auth } = require('@simtlix/simfinity-js');
const {
resolvePath, // Utility to resolve dotted paths in objects
requireAuth, // Requires ctx.user to exist
requireRole, // Requires specific role(s)
requirePermission, // Requires specific permission(s)
composeRules, // Combine rules (AND logic)
anyRule, // Combine rules (OR logic)
isOwner, // Check resource ownership
allow, // Always allow
deny, // Always deny
createRule, // Create custom rule
} = auth;
requireAuth(userPath?)
Requires the user to be authenticated. Supports custom user paths in context:
const permissions = {
Query: {
// 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:
const permissions = {
Query: {
// 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:
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):
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):
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:
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:
const permissions = {
Post: {
content: {
anyOf: [
{ eq: [{ ref: 'parent.published' }, true] },
{ eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
],
},
},
};
Supported Operators:
| Operator | Description | Example |
|---|---|---|
eq |
Equals | { eq: [{ ref: 'parent.status' }, 'active'] } |
in |
Value in array | { in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] } |
allOf |
All must be true (AND) | { allOf: [expr1, expr2] } |
anyOf |
Any must be true (OR) | { anyOf: [expr1, expr2] } |
not |
Negation | { not: { eq: [{ ref: 'parent.deleted' }, true] } } |
References:
Use { ref: 'path' } to reference values:
parent.*- Parent resolver result (the object being resolved)args.*- GraphQL argumentsctx.*- GraphQL context
Security:
- Only
parent,args, andctxroots are allowed - Unknown operators fail closed (deny)
- No
eval()orFunction()- 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.
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
// Query/Mutation names match the ones generated by simfinity.connect()
const permissions = {
Query: {
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:
applyMiddlewarefromgraphql-middlewarerebuilds the schema viamapSchema, which can cause"Schema must contain uniquely named types"errors with Simfinity schemas. UsecreateAuthPluginwith GraphQL Yoga / Envelop instead.
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
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:
const { auth } = require('@simtlix/simfinity-js');
const { UnauthenticatedError, ForbiddenError } = auth;
// UnauthenticatedError: code 'UNAUTHENTICATED', status 401
// ForbiddenError: code 'FORBIDDEN', status 403
Custom error handling in rules:
const permissions = {
Mutation: {
deleteserie: async (parent, args, ctx) => {
if (!ctx.user) {
throw new auth.UnauthenticatedError('Please log in');
}
if (ctx.user.role !== 'admin') {
throw new auth.ForbiddenError('Only admins can delete series');
}
return true;
},
},
};
Best Practices
- Default to DENY: Use
defaultPolicy: 'DENY'for security - Use wildcards wisely:
'*'rules provide baseline security per type - Prefer helper rules: Use
requireAuth(),requireRole()over custom functions - Fail closed: Custom rules should deny on unexpected conditions
- Keep rules simple: Complex logic belongs in controllers, not auth rules
- 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:
// Basic logging middleware
simfinity.use((params, next) => {
console.log(`Executing ${params.operation} on ${params.type?.name || 'custom mutation'}`);
next();
});
Middleware Parameters
Each middleware receives a params object containing:
simfinity.use((params, next) => {
// params object contains:
const {
type, // Type information (model, gqltype, controller, etc.)
args, // GraphQL arguments passed to the operation
operation, // Operation type: 'save', 'update', 'delete', 'get_by_id', 'find', 'state_changed', 'custom_mutation'
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 mutation name (only present for custom_mutation operations)
} = params;
// Always call next() to continue the middleware chain
next();
});
Common Use Cases
1. Authentication & Authorization
simfinity.use((params, next) => {
const { context, operation, type } = params;
// Skip authentication for read operations
if (operation === 'get_by_id' || operation === 'find') {
return next();
}
// Check if user is authenticated
if (!context.user) {
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
}
// Check permissions for specific types
if (type?.name === 'User' && context.user.role !== 'admin') {
throw new simfinity.SimfinityError('Admin access required', 'FORBIDDEN', 403);
}
next();
});
2. Request Logging & Monitoring
simfinity.use((params, next) => {
const { operation, type, args, context } = params;
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] Starting ${operation}${type ? ` on ${type.name}` : ''}`);
// Continue with the operation
next();
const duration = Date.now() - startTime;
console.log(`[${new Date().toISOString()}] Completed ${operation} in ${duration}ms`);
});
3. Input Validation & Sanitization
simfinity.use((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?.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);
}
}
next();
});
4. Rate Limiting
const requestCounts = new Map();
simfinity.use((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);
}
next();
});
5. Audit Trail
simfinity.use((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?.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));
}
next();
});
Multiple Middlewares
Middlewares execute in registration order. Each middleware must call next() to continue the chain:
// Middleware 1: Authentication
simfinity.use((params, next) => {
console.log('1. Checking authentication...');
// Authentication logic here
next(); // Continue to next middleware
});
// Middleware 2: Authorization
simfinity.use((params, next) => {
console.log('2. Checking permissions...');
// Authorization logic here
next(); // Continue to next middleware
});
// Middleware 3: Logging
simfinity.use((params, next) => {
console.log('3. Logging request...');
// Logging logic here
next(); // Continue to GraphQL operation
});
Error Handling in Middlewares
Middlewares can throw errors to stop the operation:
simfinity.use((params, next) => {
const { context, operation } = params;
try {
// Validation logic
if (!context.user && operation !== 'find') {
throw new simfinity.SimfinityError('Authentication required', 'UNAUTHORIZED', 401);
}
next(); // Continue only if validation passes
} catch (error) {
// Error automatically bubbles up to GraphQL error handling
throw error;
}
});
Conditional Middleware Execution
Execute middleware logic conditionally based on operation type or context:
simfinity.use((params, next) => {
const { operation, type, context } = params;
// Only apply to specific types
if (type?.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...`);
}
next();
});
Best Practices
- Always call
next(): Failing to callnext()will hang the request - Handle errors gracefully: Use try-catch blocks for error-prone operations
- Keep middlewares focused: Each middleware should handle one concern
- Order matters: Register middlewares in logical order (auth → validation → logging)
- Performance consideration: Middlewares run on every operation, keep them lightweight
- Use context wisely: Store request-specific data in the GraphQL context object
Advanced Features
Field Extensions
Control field behavior with extensions:
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: GraphQLID },
title: {
type: GraphQLString,
extensions: {
unique: true, // Creates unique index in MongoDB
readOnly: true // Excludes from input types
}
},
isbn: {
type: GraphQLString,
extensions: {
unique: true
}
}
})
});
Custom Mutations
Register custom mutations beyond the automatic CRUD operations:
simfinity.registerMutation(
'sendBookNotification',
'Send notification about a book',
BookNotificationInput, // Input type
NotificationResult, // Output type
async (args, session) => {
// Custom business logic
const book = await BookModel.findById(args.bookId);
// Send notification logic here
return { success: true, message: 'Notification sent' };
}
);
Adding Types Without Endpoints
Include types in the schema without generating endpoints. See the detailed guide on addNoEndpointType() for when and how to use this pattern:
// This type can be used in relationships but won't have queries/mutations
simfinity.addNoEndpointType(AddressType);
Working with Existing Mongoose Models
Use your existing Mongoose models:
const mongoose = require('mongoose');
const BookSchema = new mongoose.Schema({
title: String,
author: String,
publishedDate: Date
});
const BookModel = mongoose.model('Book', BookSchema);
// Use existing model
simfinity.connect(BookModel, BookType, 'book', 'books');
Programmatic Data Access
Access data programmatically outside of GraphQL:
// 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);
MCP Generation
Simfinity can automatically expose every generated GraphQL operation as a Model Context Protocol (MCP) tool. For each connected type you get one MCP tool per generated query and mutation — exactly mirroring the GraphQL surface:
| GraphQL operation | MCP tool name |
|---|---|
| Single-entity query | book |
| List query | books |
| Aggregate query | books_aggregate |
| Add mutation | addbook |
| Update mutation | updatebook |
| Delete mutation | deletebook |
| State-machine action | publish_book |
| Custom mutation | the registered name |
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),AND/ORgroups, pagination and sorting; aggregate tools additionally document theSUM/COUNT/AVG/MIN/MAXoperations and theaggregationargument.title: a human-readable label (e.g.List Book,Create Book,Aggregate Book).inputSchema: JSON Schema derived with full fidelity from the GraphQL arguments (scalars, enums, input objects, lists, non-null wrappers, and recursive filter types such asQLFilterGroupvia$defs/$ref). GraphQL type and field descriptions are propagated, with curated descriptions for the synthetic filter/pagination/sort/aggregation types.Date/DateTime/Timescalars map tostringwith the matching JSON Schemaformat(date/date-time/time), and arguments with a GraphQL default value carry a JSON Schemadefaultand are never listed asrequired.outputSchema: JSON Schema describing the returned data (mirroring the auto-generated selection set), with type/field descriptions. Every nullable GraphQL position also acceptsnull(e.g.type: ['string', 'null']; enums getnullappended), so validating MCP clients accept thenulls GraphQL legitimately returns instructuredContent(get-by-id misses, unset optional fields). Non-null positions stay single-typed, and input schemas are never null-widened.annotations: behavioral hints — queries arereadOnlyHint: true,delete*isdestructiveHint: true,update*isidempotentHint: true. Generated CRUD mutations are recognized by the placeholder descriptions Simfinity stamps on them, so a custom mutation that happens to be namedupdateReport(and carries its own description) keeps that description and gets no inferred hints.
On execution, each tool returns both a serialized JSON text content block and a machine-readable structuredContent (the GraphQL data) that conforms to the outputSchema. When the caller requests pagination: { count: true } on a list tool, the total record count is delivered in the tool result _meta.count (aggregate tools ignore the flag — their resolver never computes a total).
The MCP transports require the optional
@modelcontextprotocol/sdkdependency (^1.13.0or newer). Install it withnpm install @modelcontextprotocol/sdk.generateMCPToolsworks without it. SDK load problems raise distinct error codes:MCP_SDK_NOT_INSTALLED(not installed),MCP_SDK_INCOMPATIBLE(installed but too old to provide the requested transport — upgrade it),MCP_SDK_LOAD_FAILED(any other import failure).
Installation
npm install @modelcontextprotocol/sdk
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:
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.
Standalone MCP server (stdio)
Use stdio for a standalone executable consumed by clients like Cursor or Claude Desktop:
import * as simfinity from '@simtlix/simfinity-js';
const schema = simfinity.createSchema();
await simfinity.startStdioMCPServer(schema, {
serverName: 'my-api-mcp',
context: { user: { id: 'system' } },
});
HTTP MCP endpoint (alongside GraphQL)
Mount an MCP endpoint (Streamable HTTP) next to your existing GraphQL server:
import express from 'express';
import * as simfinity from '@simtlix/simfinity-js';
const schema = simfinity.createSchema();
const app = express();
app.use(express.json());
const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
// In HTTP mode the context factory receives the Express `req`,
// so you can authenticate per request (e.g. from the Authorization header).
context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
});
app.post('/mcp', mcpHandler);
For deployments reachable from a browser (especially localhost), enable the SDK's DNS-rebinding protection via transportOptions, and observe failures with onError:
const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
// Spread into the SDK's StreamableHTTPServerTransport options
transportOptions: {
enableDnsRebindingProtection: true,
allowedHosts: ['127.0.0.1:3000', 'localhost:3000'],
allowedOrigins: ['http://localhost:3000'],
},
onError: (err, req, res) => console.error('MCP request failed', err),
});
The handler catches all errors itself: onError is for logging/metrics, and a JSON-RPC internal error (HTTP 500) is sent automatically when response headers were not already written.
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 } }. See Remote execution. |
context |
{} |
GraphQL context value, or a factory. For in-process/stdio it is called as (extra) => context; for the HTTP handler it is called as (req, extra) => context so you can authenticate per request. Passed through to execution so scope functions and auth rules work (they read context.user). |
include / exclude |
— | String or array: raw GraphQL field names, prefixed tool names, or the categories 'query' / 'mutation'. exclude wins over include. |
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). |
includeId |
true |
Always select id when nothing else is selectable on an object type. |
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, includeId, selection }. See Customizing tools. |
toolMiddleware |
— | Array of koa-style async (call, next) functions run around every tool call. See Tool middleware. |
limits |
— | Guardrails: { maxPageSize, defaultPagination, maxResultBytes }. See Limits. |
schemaPlugins |
— | Envelop-style plugins whose onSchemaChange hook is applied once before serving (in-process execution only). See Authentication. |
serverName / serverVersion |
'simfinity-mcp' / '1.0.0' |
Identity reported by the MCP server. |
transportOptions |
— | createHTTPMCPHandler only: extra options spread into the SDK's StreamableHTTPServerTransport (e.g. enableDnsRebindingProtection, allowedHosts, allowedOrigins). |
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. |
Selection sets always fall back to id and then __typename when nothing else is selectable (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:
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/excludeentries match the raw GraphQL field name, the prefixed tool name, or the categories'query'/'mutation'.excludealways wins overinclude.includeTypes/excludeTypesmatch the entity (return) type. Aggregate queries resolve their entity through the sibling list field (books_aggregate→books), soexcludeTypes: 'Book'also hides the aggregate tool. With anincludeTypesallowlist, 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 useinclude/exclude.
Customizing tools
toolOverrides tunes individual tools without touching the schema. Keys accept either the published tool name or the unprefixed field name:
const { tools } = simfinity.generateMCPTools(schema, {
toolOverrides: {
books: {
title: 'Search books',
description: 'Search the book catalog. Filter by title, author or publication year.',
selectionDepth: 2, // deeper selection set for this tool only
},
publish_book: {
annotations: { idempotentHint: true }, // merged over the generated annotations
},
book: {
selection: 'id title author { name }', // explicit selection set
},
},
});
selection accepts 'id title' or '{ id title }' and replaces the auto-generated selection set. 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.
Limits
limits protects agents (and your database) from oversized requests and responses. Violations come back as regular isError tool results, not exceptions:
const { callTool } = simfinity.generateMCPTools(schema, {
limits: {
maxPageSize: 100, // MCP_PAGE_SIZE_EXCEEDED above this
defaultPagination: { page: 1, size: 20 }, // injected when the caller sends none
maxResultBytes: 262144, // MCP_RESULT_TOO_LARGE above this
},
});
maxPageSize: rejects calls whosepagination.sizeexceeds the cap.defaultPagination: injected only for tools that have apaginationargument, and only when the caller did not send one.pageandsizeare both required (QLPagination declares them non-null), andsizemust not exceedmaxPageSize— that misconfiguration throwsMCP_INVALID_LIMITSat setup.maxResultBytes: rejects results whose serializeddatais larger than the cap (the error message suggests narrowing the query, loweringselectionDepthor paginating).
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 }:
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.
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:
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
},
});
timeoutMsaborts slow requests (AbortSignal.timeout); the timeout signal is combined with the MCP client's cancellation signal.retryre-issues failed queries only — never mutations — after network errors/timeouts, HTTP 5xx and 429 responses. Backoff is linear: the first retry waitsbackoffMs, the second2 × backoffMs, and so on (defaultbackoffMs: 250).- Transport failures do not throw: they surface as
isErrortool results carrying GraphQL-shaped errors with the codesMCP_REMOTE_HTTP_ERROR(non-2xx response, includesextensions.status),MCP_REMOTE_REQUEST_FAILED(network error/timeout) andMCP_REMOTE_INVALID_RESPONSE(non-JSON body, or JSON with neitherdatanorerrors). - If the remote response carries a numeric
extensions.count(e.g. exposed viasimfinity.plugins.envelopCountPlugin()), it is surfaced as_meta.counton the tool result.
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: data, isError: false }. Failures and edge cases behave as follows:
- Total count: with
pagination: { count: true }on a list tool, the count lands in_meta.counton the tool result — in-process it is read back from a per-call context layer (Simfinity's find resolver writes it there; counts never leak across calls), in remote mode from the responseextensions.count. Aggregate tools never deliver a count. - Execution errors:
isError: truewith atextpayload of{ errors, data? }— partial data is preserved alongside the errors when GraphQL returned any.structuredContentis omitted on errors. - Remote transport failures and limit violations are also
isErrorresults (see Remote execution and Limits for the codes). - Unknown tool names are the exception:
callToolandgetOperationthrowMCP_TOOL_NOT_FOUND. A call whoseextra.signalis already aborted throwsMCP_CALL_CANCELLEDbefore executing.
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 functions read the context on every execution and need no extra wiring.
Field-level rules from createAuthPlugin are different. The auth plugin is an Envelop plugin that wraps resolvers from its onSchemaChange hook — a hook that only fires when an Envelop/Yoga server boots. A standalone MCP server executes operations with bare graphql(), so without help the hook never fires and the field-level rules are silently skipped. Pass the plugin via schemaPlugins to get auth parity: every plugin's onSchemaChange is invoked once before serving (in-process execution only):
import * as simfinity from '@simtlix/simfinity-js';
const { createAuthPlugin, requireAuth, requireRole } = simfinity.auth;
// Permissions are keyed by the schema's type names. Simfinity names its
// roots `RootQueryType` and `Mutation`.
const permissions = {
RootQueryType: { books: requireAuth() },
Mutation: { deletebook: requireRole('admin') },
};
const schema = simfinity.createSchema();
await simfinity.startStdioMCPServer(schema, {
// requireRole reads `user.role` by default (see rolePath to customize).
context: { user: { id: 'agent', role: 'admin' } },
schemaPlugins: [createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' })],
});
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 valuesCOUNT: Count of recordsAVG: Average of numeric valuesMIN: Minimum valueMAX: Maximum value
QLTypeAggregationFact (Input)
input QLTypeAggregationFact {
operation: QLAggregationOperation!
factName: String!
path: String!
}
QLTypeAggregationExpression (Input)
input QLTypeAggregationExpression {
groupId: String!
facts: [QLTypeAggregationFact!]!
}
QLTypeAggregationResult (Output)
type QLTypeAggregationResult {
groupId: JSON
facts: JSON
}
Quick Examples
Simple Group By
query {
series_aggregate(
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
]
}
) {
groupId
facts
}
}
Group By Related Entity
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
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
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
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)
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:
Direct Fields: Simple field names from the entity
- Example:
"category","rating","id"
- Example:
Related Entity Fields: Dot notation for fields in related entities
- Example:
"country.name","studio.foundedYear"
- Example:
Nested Related Entities: Multiple levels of relationships
- Example:
"country.region.name"
- Example:
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
fieldparameter to:"groupId"to sort by the grouping field- Any fact name (e.g.,
"avgRating","total") to sort by that calculated metric
- The
orderparameter (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
- The
pageandsizeparameters work as expected - The
countparameter is ignored for aggregation queries - Pagination is applied after grouping and sorting
MongoDB Translation
Aggregation queries are translated to efficient MongoDB aggregation pipelines:
- $lookup: Joins with related entity collections
- $unwind: Flattens joined arrays
- $match: Applies filters (before grouping)
- $group: Groups by the specified field with aggregation operations
- $project: Formats final output with groupId and facts fields
- $sort: Sorts results by groupId or facts (with multiple fields support)
- $limit / $skip: Applied for pagination (after sorting)
Result Structure
Results are returned in a consistent format:
{
"groupId": <value>,
"facts": {
"factName1": <calculated_value>,
"factName2": <calculated_value>
}
}
For complete documentation with more examples, see AGGREGATION_EXAMPLE.md and AGGREGATION_CHANGES_SUMMARY.md.
Complete Example
Here's a complete bookstore example with relationships, validations, and state machines:
const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const mongoose = require('mongoose');
const {
GraphQLObjectType,
GraphQLString,
GraphQLNonNull,
GraphQLID,
GraphQLList,
GraphQLInt,
GraphQLEnumType
} = require('graphql');
const simfinity = require('@simtlix/simfinity-js');
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/bookstore', {
useNewUrlParser: true,
useUnifiedTopology: true,
});
// Define Types
const AuthorType = new GraphQLObjectType({
name: 'Author',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
email: { type: GraphQLString },
books: {
type: new GraphQLList(BookType),
extensions: {
relation: {
connectionField: 'author',
displayField: 'title'
},
},
resolve(parent) {
return simfinity.getModel(BookType).find({ author: parent.id });
}
},
}),
});
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'
},
},
resolve(parent) {
return simfinity.getModel(AuthorType).findById(parent.author);
}
},
}),
});
// 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,
formatError: simfinity.buildErrorFormatter((err) => {
console.log(err);
})
}));
app.listen(4000, () => {
console.log('Bookstore API running on http://localhost:4000/graphql');
});
Resources
- Series Sample Project - A complete TV series microservice built with Simfinity.js demonstrating types, relationships, state machines, controllers, and authorization
- Samples Repository - Complete examples and use cases
- MongoDB Query Language - Learn about MongoDB querying
- GraphQL Documentation - Learn about GraphQL
License
Apache-2.0 License - see the LICENSE file for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Built with by 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:
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":
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":
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:
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
Find series named "Breaking Bad" that have at least one season with number 1:
query {
series(
name: {
operator: EQ,
value: "Breaking Bad"
}
seasons: {
terms: [
{
path: "number",
operator: EQ,
value: 1
}
]
}
) {
id
name
director {
name
country
}
seasons {
number
episodes {
name
}
}
}
}
6. Complex Nested Queries
Get complete information for a specific series:
query {
series(name: {
operator: EQ,
value: "Breaking Bad"
}) {
id
name
categories
director {
name
country
}
seasons {
number
year
state
episodes {
number
name
date
}
}
}
}
7. Episodes from a Specific Season and Series
Find all episodes from Season 1 of Breaking Bad:
query {
episodes(season: {
terms: [
{
path: "number",
operator: EQ,
value: 1
},
{
path: "serie.name",
operator: EQ,
value: "Breaking Bad"
}
]
}) {
id
number
name
date
season {
number
serie {
name
}
}
}
}
8. Series by Category
Find all crime series:
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:
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:
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. You need to configure a plugin to expose it. Simfinity.js provides utility plugins for both Apollo Server and Envelop:
const simfinity = require('@simtlix/simfinity-js');
// For Envelop
const getEnveloped = envelop({
plugins: [
useSchema(schema),
simfinity.plugins.envelopCountPlugin(),
],
});
// For Apollo Server
const server = new ApolloServer({
schema,
plugins: [
simfinity.plugins.apolloCountPlugin(),
],
});
See the Plugins for Count in Extensions section for complete examples.
Example Response:
{
"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
Simfinity.js supports sorting with multiple fields and sort orders:
query {
series(
categories: { operator: EQ, value: "Crime" }
pagination: { page: 1, size: 5, count: true }
sort: {
terms: [
{
field: "name",
order: DESC
}
]
}
) {
id
name
categories
director {
name
country
}
}
}
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) orDESC(descending)
Sorting by Nested Fields:
You can sort by fields from related/nested objects using dot notation:
query {
series(
categories: { operator: EQ, value: "Drama" }
pagination: { page: 1, size: 5, count: true }
sort: {
terms: [
{
field: "director.name",
order: DESC
}
]
}
) {
id
name
categories
director {
name
country
}
}
}
Multiple Sort Fields:
You can sort by multiple fields with different orders:
query {
series(
sort: {
terms: [
{ field: "director.country", order: ASC },
{ field: "name", order: DESC }
]
}
) {
id
name
director {
name
country
}
}
}
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:
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
const { GraphQLEnumType } = require('graphql');
const seasonState = new GraphQLEnumType({
name: 'seasonState',
values: {
SCHEDULED: { value: 'SCHEDULED' },
ACTIVE: { value: 'ACTIVE' },
FINISHED: { value: 'FINISHED' }
}
});
Step 2: Use Enum in GraphQL Object Type
const seasonType = new GraphQLObjectType({
name: 'season',
fields: () => ({
id: { type: GraphQLID },
number: { type: GraphQLInt },
year: { type: GraphQLInt },
state: { type: seasonState }, // ← Use the enum type
// ... other fields
})
});
Step 3: Define State Machine with Enum Values
const stateMachine = {
initialState: seasonState.getValue('SCHEDULED'),
actions: {
activate: {
from: seasonState.getValue('SCHEDULED'),
to: seasonState.getValue('ACTIVE'),
action: async (params) => {
console.log('Season activated:', JSON.stringify(params));
}
},
finalize: {
from: seasonState.getValue('ACTIVE'),
to: seasonState.getValue('FINISHED'),
action: async (params) => {
console.log('Season finalized:', JSON.stringify(params));
}
}
}
};
// Connect type with state machine
simfinity.connect(null, seasonType, 'season', 'seasons', null, null, stateMachine);
Season States
The Season entity has three states:
- SCHEDULED - Initial state when season is created
- ACTIVE - Season is currently airing
- FINISHED - Season has completed airing
State Transitions
Available transitions:
activate: SCHEDULED → ACTIVEfinalize: ACTIVE → FINISHED
State Machine Mutations
Simfinity.js automatically generates state transition mutations:
# Activate a scheduled season
mutation {
activateseason(id: "season_id_here") {
id
number
year
state
serie {
name
}
}
}
# Finalize an active season
mutation {
finalizeseason(id: "season_id_here") {
id
number
year
state
serie {
name
}
}
}
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:
query {
seasons(state: {
operator: EQ,
value: ACTIVE
}) {
id
number
year
state
serie {
name
}
}
}
State Machine Best Practices
- GraphQL Enum Types: Always define states as GraphQL enums for type safety
- getValue() Method: Use
enumType.getValue('VALUE')for state machine configuration - Initial State: Define clear initial state using enum values
- Linear Flows: Design logical progression (SCHEDULED → ACTIVE → FINISHED)
- Type Safety: GraphQL enums provide validation and autocomplete
- Actions: Implement side effects in transition actions
- 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
# 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:
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:
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
- Import the Plugin: Use
simfinity.plugins.envelopCountPlugin()orsimfinity.plugins.apolloCountPlugin()depending on your GraphQL server. - Configure Context: Ensure that your context includes the count value when executing queries (Simfinity.js automatically sets
context.countwhencount: trueis specified in pagination). - Access Count: The count will be available in the
extensionsfield of the GraphQL response.
Example Response
When the plugin is correctly set up, your GraphQL response will include the count in the extensions:
{
"data": {
"series": [
{
"id": "1",
"name": "Breaking Bad",
"categories": ["Crime", "Drama"],
"director": {
"name": "Vince Gilligan",
"country": "United States"
}
}
]
},
"extensions": {
"count": 15
}
}
This setup allows you to efficiently manage and display pagination information in your GraphQL applications.
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:
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:
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:
- No Circular Imports: Each file can import
getTypewithout importing other type definitions - Lazy Resolution: Types are resolved at schema creation time when all types are registered
- Type Safety: Still maintains GraphQL type checking and validation
- Clean Architecture: Separates type definitions from type relationships
- 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
// 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:
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
Example:
const BookInput = simfinity.getInputType(BookType);
console.log(BookInput.getFields()); // Input fields for mutations
saveObject(typeName, args, session?, context?)
Programmatically save an object outside of GraphQL mutations.
Parameters:
typeName(string): The name of the GraphQL typeargs(object): The data to savesession(MongooseSession, optional): Database session for transactionscontext(object, optional): GraphQL context object (includes request info, user data, etc.)
Returns:
Promise<object>: The saved object
Example:
const newBook = await simfinity.saveObject('Book', {
title: 'New Book',
author: 'Author Name'
}, session, context);
// Without context (context will be undefined in controller hooks)
const newBook = await simfinity.saveObject('Book', {
title: 'New Book',
author: 'Author Name'
}, session);
Note: When context is not provided, it will be undefined in controller hooks. This is acceptable for programmatic usage where context may not be available.
createSchema(includedQueryTypes?, includedMutationTypes?, includedCustomMutations?)
Creates the final GraphQL schema with all connected types.
Parameters:
includedQueryTypes(array, optional): Limit query types to includeincludedMutationTypes(array, optional): Limit mutation types to includeincludedCustomMutations(array, optional): Limit custom mutations to include
Returns:
GraphQLSchema: The complete GraphQL schema
Example:
const schema = simfinity.createSchema();
Built with by Simtlix