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.
- Features
- Installation
- Quick Start
- Core Concepts
- Basic Usage
- Middlewares
- Authorization Middleware
- Relationships
- Controllers & Lifecycle Hooks
- State Machines
- Validations
- Query Scope
- Advanced Features
- Aggregation Queries
- Complete Example
- Resources
- License
- Contributing
- Query Examples from Series-Sample
- State Machine Example from Series-Sample
- Plugins for Count in Extensions
- API Reference
- 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 Middleware: Production-grade GraphQL authorization with RBAC/ABAC, function-based rules, and declarative policy expressions
npm install mongoose graphql @simtlix/simfinity-jsPrerequisites: Simfinity.js requires mongoose and graphql as peer dependencies.
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();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 },
}),
});// Connect the type to Simfinity
simfinity.connect(null, BookType, 'book', 'books');
// Create the GraphQL schema
const schema = simfinity.createSchema();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');
});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
}
}The simfinity.connect() method links your GraphQL types to Simfinity's automatic schema generation:
simfinity.connect(
mongooseModel, // Optional: Custom Mongoose model (null for auto-generation)
graphQLType, // Required: Your GraphQLObjectType
singularEndpointName, // Required: Singular name for mutations (e.g., 'book')
pluralEndpointName, // Required: Plural name for queries (e.g., 'books')
controller, // Optional: Controller with lifecycle hooks
onModelCreated, // Optional: Callback when Mongoose model is created
stateMachine // Optional: State machine configuration
);Generate your complete GraphQL schema with optional type filtering:
const schema = simfinity.createSchema(
includedQueryTypes, // Optional: Array of types to include in queries
includedMutationTypes, // Optional: Array of types to include in mutations
includedCustomMutations // Optional: Array of custom mutations to include
);// Prevent automatic MongoDB collection creation (useful for testing)
simfinity.preventCreatingCollection(true);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 booksSimfinity automatically generates mutations for each connected type:
// For a BookType, you get:
// - addBook(input: BookInput): Book
// - updateBook(input: BookInputForUpdate): Book
// - deleteBook(id: ID): BookQuery with powerful filtering options:
query {
books(
title: { operator: LIKE, value: "Galaxy" }
author: { operator: EQ, value: "Douglas Adams" }
pagination: { page: 1, size: 10, count: true }
sort: { terms: [{ field: "title", order: ASC }] }
) {
id
title
author
}
}EQ- EqualNE- Not equalGT- Greater thanLT- Less thanGTE- Greater than or equalLTE- Less than or equalLIKE- Pattern matchingIN- In arrayNIN- Not in arrayBTW- Between two values
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.
Filter collection fields using the same operators and format as main queries:
query {
series {
seasons(number: { operator: EQ, value: 1 }) {
number
id
year
}
}
}You can use complex filtering with nested object properties:
query {
series {
seasons(
year: { operator: GTE, value: 2020 }
episodes: {
terms: [
{
path: "name",
operator: LIKE,
value: "Pilot"
}
]
}
) {
number
year
episodes {
name
date
}
}
}
}Combine multiple filter conditions for collection fields:
query {
series {
seasons(
number: { operator: GT, value: 1 }
year: { operator: BTW, value: [2015, 2023] }
) {
number
year
state
}
}
}Filter deeply nested collections using dot notation:
query {
series {
seasons(
episodes: {
terms: [
{
path: "name",
operator: LIKE,
value: "Final"
}
]
}
) {
number
episodes {
name
date
}
}
}
}Use array operations for collection fields:
query {
series {
seasons(
categories: { operator: IN, value: ["Drama", "Crime"] }
) {
number
categories
}
}
}Note: Collection field filtering uses the exact same format as main query filtering, ensuring consistency across your GraphQL API. All available operators (EQ, NE, GT, LT, GTE, LTE, LIKE, IN, NIN, BTW) work with collection fields.
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.
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();
});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();
});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();
});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`);
});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();
});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();
});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();
});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
});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;
}
});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();
});- 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
Simfinity.js provides a production-grade centralized GraphQL authorization middleware supporting RBAC/ABAC, function-based rules, declarative policy expressions (JSON AST), wildcard permissions, and configurable default policies.
const { auth } = require('@simtlix/simfinity-js');
const { applyMiddleware } = require('graphql-middleware');
const { createAuthMiddleware, requireAuth, requireRole } = auth;
// Define your permission schema
const permissions = {
Query: {
users: requireAuth(),
adminDashboard: requireRole('ADMIN'),
},
Mutation: {
publishPost: requireRole('EDITOR'),
},
User: {
'*': requireAuth(), // Wildcard: all fields require auth
email: requireRole('ADMIN'), // Override: email requires ADMIN role
},
Post: {
'*': requireAuth(),
content: async (post, _args, ctx) => {
// Custom logic: allow if published OR if author
if (post.published) return true;
if (post.authorId === ctx.user?.id) return true;
return false;
},
},
};
// Create and apply the middleware
const authMiddleware = createAuthMiddleware(permissions, { defaultPolicy: 'DENY' });
const schemaWithAuth = applyMiddleware(schema, authMiddleware);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
Simfinity.js provides reusable rule builders:
const { auth } = require('@simtlix/simfinity-js');
const {
resolvePath, // Utility to resolve dotted paths in objects
requireAuth, // Requires ctx.user to exist
requireRole, // Requires specific role(s)
requirePermission, // Requires specific permission(s)
composeRules, // Combine rules (AND logic)
anyRule, // Combine rules (OR logic)
isOwner, // Check resource ownership
allow, // Always allow
deny, // Always deny
createRule, // Create custom rule
} = auth;Requires the user to be authenticated. Supports custom user paths in context:
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'),
},
};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',
}),
},
};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',
}),
},
};Combines multiple rules with AND logic (all must pass):
const permissions = {
Mutation: {
updatePost: composeRules(
requireAuth(),
requireRole('EDITOR'),
async (post, args, ctx) => post.authorId === ctx.user.id,
),
},
};Combines multiple rules with OR logic (any must pass):
const permissions = {
Post: {
content: anyRule(
requireRole('ADMIN'),
async (post, args, ctx) => post.authorId === ctx.user.id,
),
},
};Checks if the authenticated user owns the resource:
const permissions = {
Post: {
'*': composeRules(
requireAuth(),
isOwner('authorId', 'id'), // Compares post.authorId with ctx.user.id
),
},
};For declarative rules, use JSON AST policy expressions:
const permissions = {
Post: {
content: {
anyOf: [
{ eq: [{ ref: 'parent.published' }, true] },
{ eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
],
},
},
};Supported Operators:
| Operator | Description | Example |
|---|---|---|
eq |
Equals | { eq: [{ ref: 'parent.status' }, 'active'] } |
in |
Value in array | { in: [{ ref: 'ctx.user.role' }, ['ADMIN', 'MOD']] } |
allOf |
All must be true (AND) | { allOf: [expr1, expr2] } |
anyOf |
Any must be true (OR) | { anyOf: [expr1, expr2] } |
not |
Negation | { not: { eq: [{ ref: 'parent.deleted' }, true] } } |
References:
Use { ref: 'path' } to reference values:
parent.*- Parent resolver result (the object being resolved)args.*- GraphQL argumentsctx.*- GraphQL context
Security:
- Only
parent,args, andctxroots are allowed - Unknown operators fail closed (deny)
- No
eval()orFunction()- pure object traversal
The auth middleware integrates with the graphql-middleware package:
const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const { applyMiddleware } = require('graphql-middleware');
const simfinity = require('@simtlix/simfinity-js');
const { auth } = simfinity;
const { createAuthMiddleware, requireAuth, requireRole, requirePermission } = auth;
// Define your types and connect them
simfinity.connect(null, UserType, 'user', 'users');
simfinity.connect(null, PostType, 'post', 'posts');
// Create base schema
const baseSchema = simfinity.createSchema();
// Define permissions
const permissions = {
Query: {
users: requireAuth(),
user: requireAuth(),
posts: requireAuth(),
post: requireAuth(),
},
Mutation: {
adduser: requireRole('ADMIN'),
updateuser: requireRole('ADMIN'),
deleteuser: requireRole('ADMIN'),
addpost: requireAuth(),
updatepost: composeRules(requireAuth(), isOwner('authorId')),
deletepost: requireRole('ADMIN'),
},
User: {
'*': requireAuth(),
email: requireRole('ADMIN'),
password: deny('Password field is not accessible'),
},
Post: {
'*': requireAuth(),
content: {
anyOf: [
{ eq: [{ ref: 'parent.published' }, true] },
{ eq: [{ ref: 'parent.authorId' }, { ref: 'ctx.user.id' }] },
],
},
},
};
// Create auth middleware
const authMiddleware = createAuthMiddleware(permissions, {
defaultPolicy: 'DENY', // Deny access when no rule matches
debug: false, // Enable for debugging
});
// Apply middleware to schema
const schema = applyMiddleware(baseSchema, authMiddleware);
// Setup Express with context
const app = express();
app.use('/graphql', graphqlHTTP((req) => ({
schema,
graphiql: true,
context: {
user: req.user, // Set by your authentication middleware
},
formatError: simfinity.buildErrorFormatter((err) => {
console.error(err);
}),
})));
app.listen(4000);const middleware = createAuthMiddleware(permissions, {
defaultPolicy: 'DENY', // 'ALLOW' or 'DENY' (default: 'DENY')
debug: false, // Enable debug logging
});| Option | Type | Default | Description |
|---|---|---|---|
defaultPolicy |
'ALLOW' | 'DENY' |
'DENY' |
Policy when no rule matches |
debug |
boolean |
false |
Log authorization decisions |
The auth middleware uses Simfinity error classes:
const { auth } = require('@simtlix/simfinity-js');
const { UnauthenticatedError, ForbiddenError } = auth;
// UnauthenticatedError: code 'UNAUTHENTICATED', status 401
// ForbiddenError: code 'FORBIDDEN', status 403Custom error handling in rules:
const permissions = {
Mutation: {
deleteAccount: async (parent, args, ctx) => {
if (!ctx.user) {
throw new auth.UnauthenticatedError('Please log in');
}
if (ctx.user.role !== 'ADMIN' && ctx.user.id !== args.id) {
throw new auth.ForbiddenError('Cannot delete other users');
}
return true;
},
},
};- 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
Use the extensions.relation field to define relationships between types:
const AuthorType = new GraphQLObjectType({
name: 'Author',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
books: {
type: new GraphQLList(BookType),
extensions: {
relation: {
connectionField: 'author',
displayField: 'title'
},
},
// resolve method automatically generated! π
},
}),
});
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
// resolve method automatically generated! π
},
}),
});connectionField: (Required for 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)
π NEW: Simfinity.js automatically generates resolve methods for relationship fields when types are connected, eliminating the need for manual resolver boilerplate.
const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
// You had to manually write this
resolve(parent) {
return simfinity.getModel(AuthorType).findById(parent.author);
}
},
comments: {
type: new GraphQLList(CommentType),
extensions: {
relation: {
connectionField: 'bookId',
displayField: 'text'
},
},
// You had to manually write this too
resolve(parent) {
return simfinity.getModel(CommentType).find({ bookId: parent.id });
}
}
}),
});const BookType = new GraphQLObjectType({
name: 'Book',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
title: { type: new GraphQLNonNull(GraphQLString) },
author: {
type: AuthorType,
extensions: {
relation: {
displayField: 'name'
},
},
// resolve method automatically generated! π
},
comments: {
type: new GraphQLList(CommentType),
extensions: {
relation: {
connectionField: 'bookId',
displayField: 'text'
},
},
// resolve method automatically generated! π
}
}),
});- 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 all your types to Simfinity
simfinity.connect(null, AuthorType, 'author', 'authors');
simfinity.connect(null, BookType, 'book', 'books');
simfinity.connect(null, CommentType, 'comment', 'comments');
// Or use addNoEndpointType for types that don't need direct queries/mutations
simfinity.addNoEndpointType(AuthorType);That's it! All relationship resolvers are automatically generated when you connect your types.
Use addNoEndpointType() for types that should be included in the GraphQL schema but don't need their own CRUD operations:
simfinity.addNoEndpointType(TypeName);When to use addNoEndpointType() vs connect():
| Method | Use Case | Creates Endpoints | Use Example |
|---|---|---|---|
connect() |
Types that need CRUD operations | β Yes | User, Product, Order |
addNoEndpointType() |
Types only used in relationships | β No | Address, Settings, Director |
From the series-sample project:
// Director type - Used only as embedded data, no direct API access needed
const directorType = new GraphQLObjectType({
name: 'director',
fields: () => ({
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
country: { type: GraphQLString }
})
});
// Add to schema WITHOUT creating endpoints
simfinity.addNoEndpointType(directorType);
// Serie type - Has its own endpoints and embeds director data
const serieType = new GraphQLObjectType({
name: 'serie',
fields: () => ({
id: { type: GraphQLID },
name: { type: new GraphQLNonNull(GraphQLString) },
categories: { type: new GraphQLList(GraphQLString) },
director: {
type: new GraphQLNonNull(directorType),
extensions: {
relation: {
embedded: true, // Director data stored within serie document
displayField: 'name'
}
}
}
})
});
// Create full CRUD endpoints for series
simfinity.connect(null, serieType, 'serie', 'series');Result:
- β
addserie,updateserie,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
}
}
}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
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
}
}
}Query nested relationships with dot notation:
query {
books(author: {
terms: [
{
path: "country.name",
operator: EQ,
value: "England"
}
]
}) {
id
title
author {
name
country {
name
}
}
}
}Link to existing objects:
mutation {
addBook(input: {
title: "New Book"
author: {
id: "existing_author_id"
}
}) {
id
title
author {
name
}
}
}Create embedded objects:
mutation {
addBook(input: {
title: "New Book"
publisher: {
name: "Penguin Books"
location: "London"
}
}) {
id
title
publisher {
name
location
}
}
}Work with arrays of related objects:
mutation {
updateBook(input: {
id: "book_id"
reviews: {
added: [
{ rating: 5, comment: "Amazing!" }
{ rating: 4, comment: "Good read" }
]
updated: [
{ id: "review_id", rating: 3 }
]
deleted: ["review_id_to_delete"]
}
}) {
id
title
reviews {
rating
comment
}
}
}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);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.)
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.
Implement declarative state machine workflows:
const { GraphQLEnumType } = require('graphql');
const OrderState = new GraphQLEnumType({
name: 'OrderState',
values: {
PENDING: { value: 'PENDING' },
PROCESSING: { value: 'PROCESSING' },
SHIPPED: { value: 'SHIPPED' },
DELIVERED: { value: 'DELIVERED' },
CANCELLED: { value: 'CANCELLED' }
}
});const OrderType = new GraphQLObjectType({
name: 'Order',
fields: () => ({
id: { type: GraphQLID },
customer: { type: GraphQLString },
state: { type: OrderState }
})
});const stateMachine = {
initialState: { name: 'PENDING', value: 'PENDING' },
actions: {
process: {
from: { name: 'PENDING', value: 'PENDING' },
to: { name: 'PROCESSING', value: 'PROCESSING' },
description: 'Process the order',
action: async (args, session) => {
// Business logic for processing
console.log(`Processing order ${args.id}`);
// You can perform additional operations here
}
},
ship: {
from: { name: 'PROCESSING', value: 'PROCESSING' },
to: { name: 'SHIPPED', value: 'SHIPPED' },
description: 'Ship the order',
action: async (args, session) => {
// Business logic for shipping
console.log(`Shipping order ${args.id}`);
}
},
deliver: {
from: { name: 'SHIPPED', value: 'SHIPPED' },
to: { name: 'DELIVERED', value: 'DELIVERED' },
description: 'Mark as delivered'
},
cancel: {
from: { name: 'PENDING', value: 'PENDING' },
to: { name: 'CANCELLED', value: 'CANCELLED' },
description: 'Cancel the order'
}
}
};simfinity.connect(null, OrderType, 'order', 'orders', null, null, stateMachine);The state machine automatically generates mutations for each action:
mutation {
process_order(input: {
id: "order_id"
}) {
id
state
customer
}
}Important Notes:
- 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
Simfinity.js provides built-in validation helpers to simplify common validation patterns, eliminating verbose boilerplate code.
const { validators } = require('@simtlix/simfinity-js');
const PersonType = new GraphQLObjectType({
name: 'Person',
fields: () => ({
id: { type: GraphQLID },
name: {
type: GraphQLString,
extensions: {
validations: validators.stringLength('Name', 2, 100)
}
},
email: {
type: GraphQLString,
extensions: {
validations: validators.email()
}
},
website: {
type: GraphQLString,
extensions: {
validations: validators.url()
}
},
age: {
type: GraphQLInt,
extensions: {
validations: validators.numberRange('Age', 0, 120)
}
},
price: {
type: GraphQLFloat,
extensions: {
validations: validators.positive('Price')
}
}
})
});String Validators:
validators.stringLength(name, min, max)- Validates string length with min/max bounds (required for CREATE)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
- 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
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)
}
}
})
});For custom validation logic, you can still write manual validators:
const { SimfinityError } = require('@simtlix/simfinity-js');
const validateAge = {
validate: async (typeName, fieldName, value, session) => {
if (value < 0 || value > 120) {
throw new SimfinityError(`Invalid age: ${value}`, 'VALIDATION_ERROR', 400);
}
}
};
const PersonType = new GraphQLObjectType({
name: 'Person',
fields: () => ({
id: { type: GraphQLID },
name: {
type: GraphQLString,
extensions: {
validations: {
save: [{
validate: async (typeName, fieldName, value, session) => {
if (!value || value.length < 2) {
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
}
}
}],
update: [{
validate: async (typeName, fieldName, value, session) => {
if (value && value.length < 2) {
throw new SimfinityError('Name must be at least 2 characters', 'VALIDATION_ERROR', 400);
}
}
}]
}
}
},
age: {
type: GraphQLInt,
extensions: {
validations: {
save: [validateAge],
update: [validateAge]
}
}
}
})
});Validate objects as a whole:
const orderValidator = {
validate: async (typeName, args, modelArgs, session) => {
// Cross-field validation
if (modelArgs.deliveryDate < modelArgs.orderDate) {
throw new SimfinityError('Delivery date cannot be before order date', 'VALIDATION_ERROR', 400);
}
// Business rule validation
if (modelArgs.items.length === 0) {
throw new SimfinityError('Order must contain at least one item', 'BUSINESS_ERROR', 400);
}
}
};
const OrderType = new GraphQLObjectType({
name: 'Order',
extensions: {
validations: {
save: [orderValidator],
update: [orderValidator]
}
},
fields: () => ({
// ... fields
})
});Create custom scalar types with built-in validation. The generated type names follow the pattern {name}_{baseScalarTypeName}.
Simfinity.js provides ready-to-use validated scalars for common patterns:
const { scalars } = require('@simtlix/simfinity-js');
const UserType = new GraphQLObjectType({
name: 'User',
fields: () => ({
id: { type: GraphQLID },
email: { type: scalars.EmailScalar }, // Type name: Email_String
website: { type: scalars.URLScalar }, // Type name: URL_String
age: { type: scalars.PositiveIntScalar }, // Type name: PositiveInt_Int
price: { type: scalars.PositiveFloatScalar } // Type name: PositiveFloat_Float
}),
});Available Pre-built Scalars:
scalars.EmailScalar- Validates email format (Email_String)scalars.URLScalar- Validates URL format (URL_String)scalars.PositiveIntScalar- Validates positive integers (PositiveInt_Int)scalars.PositiveFloatScalar- Validates positive floats (PositiveFloat_Float)
Create custom validated scalars with parameters:
const { scalars } = require('@simtlix/simfinity-js');
// Create a bounded string scalar (name length between 2-100 characters)
const NameScalar = scalars.createBoundedStringScalar('Name', 2, 100);
// Create a bounded integer scalar (age between 0-120)
const AgeScalar = scalars.createBoundedIntScalar('Age', 0, 120);
// Create a bounded float scalar (rating between 0-10)
const RatingScalar = scalars.createBoundedFloatScalar('Rating', 0, 10);
// Create a pattern-based string scalar (phone number format)
const PhoneScalar = scalars.createPatternStringScalar(
'Phone',
/^\+?[\d\s\-()]+$/,
'Invalid phone number format'
);
// Use in your types
const PersonType = new GraphQLObjectType({
name: 'Person',
fields: () => ({
id: { type: GraphQLID },
name: { type: NameScalar }, // Type name: Name_String
age: { type: AgeScalar }, // Type name: Age_Int
rating: { type: RatingScalar }, // Type name: Rating_Float
phone: { type: PhoneScalar } // Type name: Phone_String
}),
});Available Factory Functions:
scalars.createBoundedStringScalar(name, min, max)- String with length 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
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
}),
});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);
}
}Query scope allows you to automatically modify query arguments based on context (e.g., user permissions). This enables automatic filtering so that users can only see documents they're authorized to access. Scope functions are executed after middleware and before query execution, allowing you to append filter conditions to queries and aggregations.
Define scope in the type extensions, similar to how validations are defined:
const EpisodeType = new GraphQLObjectType({
name: 'episode',
extensions: {
validations: {
create: [validateEpisodeFields],
update: [validateEpisodeBusinessRules]
},
scope: {
find: async ({ type, args, operation, context }) => {
// Modify args in place to add filter conditions
args.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 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 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.
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 functions receive the same parameters as middleware for consistency:
{
type, // Type information (model, gqltype, controller, etc.)
args, // GraphQL arguments passed to the operation (modify this object)
operation, // Operation type: 'find', 'aggregate', or 'get_by_id'
context // GraphQL context object (includes request info, user data, etc.)
}When modifying args in scope functions, use the appropriate filter structure:
For scalar fields:
args.fieldName = {
operator: 'EQ',
value: 'someValue'
};For object/relation fields (QLTypeFilterExpression):
args.relationField = {
terms: [
{
path: 'fieldName',
operator: 'EQ',
value: 'someValue'
}
]
};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'
}
}
}
})
});- 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
- 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
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
}
}
})
});Register custom mutations beyond the automatic CRUD operations:
simfinity.registerMutation(
'sendBookNotification',
'Send notification about a book',
BookNotificationInput, // Input type
NotificationResult, // Output type
async (args, session) => {
// Custom business logic
const book = await BookModel.findById(args.bookId);
// Send notification logic here
return { success: true, message: 'Notification sent' };
}
);Include types in the schema without generating endpoints. See the detailed guide on addNoEndpointType() for when and how to use this pattern:
// This type can be used in relationships but won't have queries/mutations
simfinity.addNoEndpointType(AddressType);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');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);Simfinity.js now supports powerful GraphQL aggregation queries with GROUP BY functionality, allowing you to perform aggregate operations (SUM, COUNT, AVG, MIN, MAX) on your data.
For each entity type registered with connect(), an additional aggregation endpoint is automatically generated with the format {entityname}_aggregate.
- 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
SUM: Sum of numeric valuesCOUNT: Count of recordsAVG: Average of numeric valuesMIN: Minimum valueMAX: Maximum value
input QLTypeAggregationFact {
operation: QLAggregationOperation!
factName: String!
path: String!
}input QLTypeAggregationExpression {
groupId: String!
facts: [QLTypeAggregationFact!]!
}type QLTypeAggregationResult {
groupId: JSON
facts: JSON
}query {
series_aggregate(
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
]
}
) {
groupId
facts
}
}query {
series_aggregate(
aggregation: {
groupId: "country.name"
facts: [
{ operation: COUNT, factName: "count", path: "id" }
{ operation: AVG, factName: "avgRating", path: "rating" }
]
}
) {
groupId
facts
}
}query {
series_aggregate(
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
{ operation: SUM, factName: "totalEpisodes", path: "episodeCount" }
{ operation: AVG, factName: "avgRating", path: "rating" }
{ operation: MIN, factName: "minRating", path: "rating" }
{ operation: MAX, factName: "maxRating", path: "rating" }
]
}
) {
groupId
facts
}
}query {
series_aggregate(
rating: { operator: GTE, value: 8.0 }
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "highRated", path: "id" }
]
}
) {
groupId
facts
}
}query {
series_aggregate(
sort: {
terms: [
{ field: "total", order: "DESC" }, # Sort by count first
{ field: "groupId", order: "ASC" } # Then by name
]
}
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
{ operation: AVG, factName: "avgRating", path: "rating" }
]
}
) {
groupId
facts
}
}query {
series_aggregate(
sort: {
terms: [{ field: "total", order: "DESC" }]
}
pagination: {
page: 1
size: 5
}
aggregation: {
groupId: "category"
facts: [
{ operation: COUNT, factName: "total", path: "id" }
]
}
) {
groupId
facts
}
}The groupId and path parameters support:
-
Direct Fields: Simple field names from the entity
- 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:
- 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
- The
pageandsizeparameters work as expected - The
countparameter is ignored for aggregation queries - Pagination is applied after grouping and sorting
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)
Results are returned in a consistent format:
{
"groupId": <value>,
"facts": {
"factName1": <calculated_value>,
"factName2": <calculated_value>
}
}For complete documentation with more examples, see AGGREGATION_EXAMPLE.md and AGGREGATION_CHANGES_SUMMARY.md.
Here's a complete bookstore example with relationships, validations, and state machines:
const express = require('express');
const { graphqlHTTP } = require('express-graphql');
const mongoose = require('mongoose');
const {
GraphQLObjectType,
GraphQLString,
GraphQLNonNull,
GraphQLID,
GraphQLList,
GraphQLInt,
GraphQLEnumType
} = require('graphql');
const simfinity = require('@simtlix/simfinity-js');
// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/bookstore', {
useNewUrlParser: true,
useUnifiedTopology: true,
});
// Define Types
const AuthorType = new GraphQLObjectType({
name: 'Author',
fields: () => ({
id: { type: new GraphQLNonNull(GraphQLID) },
name: { type: new GraphQLNonNull(GraphQLString) },
email: { type: GraphQLString },
books: {
type: new GraphQLList(BookType),
extensions: {
relation: {
connectionField: 'author',
displayField: 'title'
},
},
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');
});- Samples Repository - Complete examples and use cases
- MongoDB Query Language - Learn about MongoDB querying
- GraphQL Documentation - Learn about GraphQL
Apache-2.0 License - see the LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request.
Built with β€οΈ by Simtlix
Here are some practical GraphQL query examples from the series-sample project, showcasing how to use simfinity.js effectively:
Find all series that have directors from the United States:
query {
series(director: {
terms: [
{
path: "country",
operator: EQ,
value: "United States"
}
]
}) {
id
name
categories
director {
name
country
}
}
}Find series that contain an episode with the name "Pilot":
query {
series(
seasons: {
terms: [
{
path: "episodes.name",
operator: EQ,
value: "Pilot"
}
]
}
) {
id
name
seasons {
number
episodes {
number
name
date
}
}
}
}Find series that feature "Bryan Cranston":
query {
assignedStarsAndSeries(star: {
terms: [
{
path: "name",
operator: EQ,
value: "Bryan Cranston"
}
]
}) {
id
star {
name
}
serie {
id
name
categories
director {
name
country
}
}
}
}Find all seasons that belong to series directed by someone from the United States:
query {
seasons(serie: {
terms: [
{
path: "director.country",
operator: EQ,
value: "United States"
}
]
}) {
id
number
year
state
serie {
name
categories
director {
name
country
}
}
episodes {
number
name
date
}
}
}Find series named "Breaking Bad" that have at least one season with number 1:
query {
series(
name: {
operator: EQ,
value: "Breaking Bad"
}
seasons: {
terms: [
{
path: "number",
operator: EQ,
value: 1
}
]
}
) {
id
name
director {
name
country
}
seasons {
number
episodes {
name
}
}
}
}Get complete information for a specific series:
query {
series(name: {
operator: EQ,
value: "Breaking Bad"
}) {
id
name
categories
director {
name
country
}
seasons {
number
year
state
episodes {
number
name
date
}
}
}
}Find all episodes from Season 1 of Breaking Bad:
query {
episodes(season: {
terms: [
{
path: "number",
operator: EQ,
value: 1
},
{
path: "serie.name",
operator: EQ,
value: "Breaking Bad"
}
]
}) {
id
number
name
date
season {
number
serie {
name
}
}
}
}Find all crime series:
query {
series(categories: {
operator: EQ,
value: "Crime"
}) {
id
name
categories
director {
name
country
}
}
}Find episodes containing "Fire" in the name:
query {
episodes(name: {
operator: LIKE,
value: "Fire"
}) {
id
number
name
date
season {
number
serie {
name
}
}
}
}Simfinity.js supports built-in pagination with optional total count:
query {
series(
categories: {
operator: EQ,
value: "Crime"
}
pagination: {
page: 1,
size: 2,
count: true
}
) {
id
name
categories
director {
name
country
}
}
}- 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
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.
{
"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
}
}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
}
}
}- 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)
You can sort by fields from related/nested objects using dot notation:
query {
series(
categories: { operator: EQ, value: "Drama" }
pagination: { page: 1, size: 5, count: true }
sort: {
terms: [
{
field: "director.name",
order: DESC
}
]
}
) {
id
name
categories
director {
name
country
}
}
}You can sort by multiple fields with different orders:
query {
series(
sort: {
terms: [
{ field: "director.country", order: ASC },
{ field: "name", order: DESC }
]
}
) {
id
name
director {
name
country
}
}
}The example above demonstrates combining filtering, pagination, and sorting in a single query - a common pattern for data tables and lists with full functionality.
Find series with seasons released between 2010-2015:
query {
seasons(year: {
operator: BETWEEN,
value: [2010, 2015]
}) {
id
number
year
serie {
name
director {
name
country
}
}
}
}Simfinity.js provides built-in state machine support for managing entity lifecycles. Here's an example of how a state machine is implemented in the Season entity from the series-sample project.
State machines require GraphQL Enum Types to define states and proper state references:
Step 1: Define the GraphQL Enum Type
const { GraphQLEnumType } = require('graphql');
const seasonState = new GraphQLEnumType({
name: 'seasonState',
values: {
SCHEDULED: { value: 'SCHEDULED' },
ACTIVE: { value: 'ACTIVE' },
FINISHED: { value: 'FINISHED' }
}
});Step 2: Use Enum in GraphQL Object Type
const seasonType = new GraphQLObjectType({
name: 'season',
fields: () => ({
id: { type: GraphQLID },
number: { type: GraphQLInt },
year: { type: GraphQLInt },
state: { type: seasonState }, // β Use the enum type
// ... other fields
})
});Step 3: Define State Machine with Enum Values
const stateMachine = {
initialState: seasonState.getValue('SCHEDULED'),
actions: {
activate: {
from: seasonState.getValue('SCHEDULED'),
to: seasonState.getValue('ACTIVE'),
action: async (params) => {
console.log('Season activated:', JSON.stringify(params));
}
},
finalize: {
from: seasonState.getValue('ACTIVE'),
to: seasonState.getValue('FINISHED'),
action: async (params) => {
console.log('Season finalized:', JSON.stringify(params));
}
}
}
};
// Connect type with state machine
simfinity.connect(null, seasonType, 'season', 'seasons', null, null, stateMachine);The Season entity has three states:
- SCHEDULED - Initial state when season is created
- ACTIVE - Season is currently airing
- FINISHED - Season has completed airing
Available transitions:
activate: SCHEDULED β ACTIVEfinalize: ACTIVE β FINISHED
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
}
}
}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
}
}
}- 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
- 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
# 1. Create season (automatically SCHEDULED)
mutation {
addseason(input: {
number: 6
year: 2024
serie: "series_id_here"
}) {
id
state # Will be "SCHEDULED"
}
}
# 2. Activate season when airing begins
mutation {
activateseason(id: "season_id_here") {
id
state # Will be "ACTIVE"
}
}
# 3. Finalize season when completed
mutation {
finalizeseason(id: "season_id_here") {
id
state # Will be "FINISHED"
}
}To include the total count in the extensions of your GraphQL response, Simfinity.js provides utility plugins for both Apollo Server and Envelop. This is particularly useful for pagination and analytics.
Use simfinity.plugins.envelopCountPlugin() to add count to extensions when using Envelop:
const { envelop, useSchema } = require('@envelop/core');
const { makeExecutableSchema } = require('@graphql-tools/schema');
const simfinity = require('@simtlix/simfinity-js');
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});
const getEnveloped = envelop({
plugins: [
useSchema(schema),
simfinity.plugins.envelopCountPlugin(), // Add the count plugin here
],
});
// Use getEnveloped in your server setupUse 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
};
},
});- 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.
When the plugin is correctly set up, your GraphQL response will include the count in the extensions:
{
"data": {
"series": [
{
"id": "1",
"name": "Breaking Bad",
"categories": ["Crime", "Drama"],
"director": {
"name": "Vince Gilligan",
"country": "United States"
}
}
]
},
"extensions": {
"count": 15
}
}This setup allows you to efficiently manage and display pagination information in your GraphQL applications.
Simfinity.js provides several utility methods for programmatic access to your GraphQL types and data:
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); // nullUse 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
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();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' });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 mutationsProgrammatically 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.
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