Package Exports
- @classytic/mongokit
- @classytic/mongokit/actions
- @classytic/mongokit/pagination
- @classytic/mongokit/plugins
- @classytic/mongokit/utils
Readme
@classytic/mongokit
Production-grade MongoDB repository pattern with zero external dependencies
Works with: Express, Fastify, NestJS, Next.js, Koa, Hapi, Serverless
Features
- Zero dependencies - Only Mongoose as peer dependency
- Smart pagination - Auto-detects offset vs cursor-based
- Event-driven - Pre/post hooks for all operations
- 12 built-in plugins - Caching, soft delete, validation, audit logs, and more
- TypeScript first - Full type safety with discriminated unions
- 410+ passing tests - Battle-tested and production-ready
Installation
npm install @classytic/mongokit mongooseSupports Mongoose
^8.0.0and^9.0.0
Quick Start
import { Repository } from '@classytic/mongokit';
import UserModel from './models/User.js';
const userRepo = new Repository(UserModel);
// Create
const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
// Read with auto-detected pagination
const users = await userRepo.getAll({ page: 1, limit: 20 });
// Update
await userRepo.update(user._id, { name: 'Jane' });
// Delete
await userRepo.delete(user._id);Pagination
getAll() automatically detects pagination mode based on parameters:
// Offset pagination (page-based) - for dashboards
const result = await repo.getAll({
page: 1,
limit: 20,
filters: { status: 'active' },
sort: { createdAt: -1 }
});
// → { method: 'offset', docs, total, pages, hasNext, hasPrev }
// Keyset pagination (cursor-based) - for infinite scroll
const stream = await repo.getAll({
sort: { createdAt: -1 },
limit: 20
});
// → { method: 'keyset', docs, hasMore, next: 'eyJ2IjoxLC...' }
// Next page with cursor
const next = await repo.getAll({
after: stream.next,
sort: { createdAt: -1 },
limit: 20
});Auto-detection rules:
pageparameter → offset modeafter/cursorparameter → keyset modesortwithoutpage→ keyset mode (first page)- Default → offset mode (page 1)
Required Indexes
// For keyset pagination: sort field + _id
PostSchema.index({ createdAt: -1, _id: -1 });
// For multi-tenant: tenant + sort field + _id
UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });API Reference
CRUD Operations
| Method | Description |
|---|---|
create(data, opts) |
Create single document |
createMany(data[], opts) |
Create multiple documents |
getById(id, opts) |
Find by ID |
getByQuery(query, opts) |
Find one by query |
getAll(params, opts) |
Paginated list (auto-detects mode) |
getOrCreate(query, data, opts) |
Find or create |
update(id, data, opts) |
Update document |
delete(id, opts) |
Delete document |
count(query, opts) |
Count documents |
exists(query, opts) |
Check existence |
Aggregation
// Basic aggregation
const result = await repo.aggregate([
{ $match: { status: 'active' } },
{ $group: { _id: '$category', total: { $sum: 1 } } }
]);
// Paginated aggregation
const result = await repo.aggregatePaginate({
pipeline: [...],
page: 1,
limit: 20
});
// Distinct values
const categories = await repo.distinct('category', { status: 'active' });Transactions
await repo.withTransaction(async (session) => {
await repo.create({ name: 'User 1' }, { session });
await repo.create({ name: 'User 2' }, { session });
// Auto-commits on success, auto-rollbacks on error
});Configuration
const repo = new Repository(UserModel, plugins, {
defaultLimit: 20, // Default docs per page
maxLimit: 100, // Maximum allowed limit
maxPage: 10000, // Maximum page number
deepPageThreshold: 100, // Warn when page exceeds this
useEstimatedCount: false, // Use fast estimated counts
cursorVersion: 1 // Cursor format version
});Plugins
Using Plugins
import {
Repository,
timestampPlugin,
softDeletePlugin,
cachePlugin,
createMemoryCache
} from '@classytic/mongokit';
const repo = new Repository(UserModel, [
timestampPlugin(),
softDeletePlugin(),
cachePlugin({ adapter: createMemoryCache(), ttl: 60 })
]);Available Plugins
| Plugin | Description |
|---|---|
timestampPlugin() |
Auto-manage createdAt/updatedAt |
softDeletePlugin(opts) |
Mark as deleted instead of removing |
auditLogPlugin(logger) |
Log all CUD operations |
cachePlugin(opts) |
Redis/Memcached/memory caching with auto-invalidation |
validationChainPlugin(validators) |
Custom validation rules |
fieldFilterPlugin(preset) |
Role-based field visibility |
cascadePlugin(opts) |
Auto-delete related documents |
methodRegistryPlugin() |
Dynamic method registration (required by plugins below) |
mongoOperationsPlugin() |
Adds increment, pushToArray, upsert, etc. |
batchOperationsPlugin() |
Adds updateMany, deleteMany |
aggregateHelpersPlugin() |
Adds groupBy, sum, average, etc. |
subdocumentPlugin() |
Manage subdocument arrays |
Soft Delete
const repo = new Repository(UserModel, [
softDeletePlugin({ deletedField: 'deletedAt' })
]);
await repo.delete(id); // Marks as deleted
await repo.getAll(); // Excludes deleted
await repo.getAll({ includeDeleted: true }); // Includes deletedCaching
import { cachePlugin, createMemoryCache } from '@classytic/mongokit';
const repo = new Repository(UserModel, [
cachePlugin({
adapter: createMemoryCache(), // or Redis adapter
ttl: 60, // Default TTL (seconds)
byIdTtl: 300, // TTL for getById
queryTtl: 30, // TTL for lists
})
]);
// Reads are cached automatically
const user = await repo.getById(id);
// Skip cache for fresh data
const fresh = await repo.getById(id, { skipCache: true });
// Mutations auto-invalidate cache
await repo.update(id, { name: 'New' });
// Manual invalidation
await repo.invalidateCache(id);
await repo.invalidateAllCache();Redis adapter example:
const redisAdapter = {
async get(key) { return JSON.parse(await redis.get(key) || 'null'); },
async set(key, value, ttl) { await redis.setex(key, ttl, JSON.stringify(value)); },
async del(key) { await redis.del(key); },
async clear(pattern) { /* optional bulk delete */ }
};Validation Chain
import {
validationChainPlugin,
requireField,
uniqueField,
immutableField,
blockIf,
autoInject
} from '@classytic/mongokit';
const repo = new Repository(UserModel, [
validationChainPlugin([
requireField('email', ['create']),
uniqueField('email', 'Email already exists'),
immutableField('userId'),
blockIf('noAdminDelete', ['delete'],
(ctx) => ctx.data?.role === 'admin',
'Cannot delete admin users'),
autoInject('slug', (ctx) => slugify(ctx.data?.name), ['create'])
])
]);Cascade Delete
import { cascadePlugin, softDeletePlugin } from '@classytic/mongokit';
const repo = new Repository(ProductModel, [
softDeletePlugin(),
cascadePlugin({
relations: [
{ model: 'StockEntry', foreignKey: 'product' },
{ model: 'Review', foreignKey: 'product', softDelete: false }
],
parallel: true,
logger: console
})
]);
// Deleting product also deletes related StockEntry and Review docs
await repo.delete(productId);Field Filtering (RBAC)
import { fieldFilterPlugin } from '@classytic/mongokit';
const repo = new Repository(UserModel, [
fieldFilterPlugin({
public: ['id', 'name', 'avatar'],
authenticated: ['email', 'phone'],
admin: ['createdAt', 'internalNotes']
})
]);MongoDB Operations Plugin
The mongoOperationsPlugin adds MongoDB-specific atomic operations like increment, upsert, pushToArray, etc.
Basic Usage (No TypeScript Autocomplete)
import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
const repo = new Repository(ProductModel, [
methodRegistryPlugin(), // Required first
mongoOperationsPlugin()
]);
// Works at runtime but TypeScript doesn't provide autocomplete
await repo.increment(productId, 'views', 1);
await repo.upsert({ sku: 'ABC' }, { name: 'Product', price: 99 });With TypeScript Type Safety (Recommended)
For full TypeScript autocomplete and type checking, use the MongoOperationsMethods type:
import { Repository, methodRegistryPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
import type { MongoOperationsMethods } from '@classytic/mongokit';
// 1. Create your repository class
class ProductRepo extends Repository<IProduct> {
// Add custom methods here
async findBySku(sku: string) {
return this.getByQuery({ sku });
}
}
// 2. Create type helper for autocomplete
type ProductRepoWithPlugins = ProductRepo & MongoOperationsMethods<IProduct>;
// 3. Instantiate with type assertion
const repo = new ProductRepo(ProductModel, [
methodRegistryPlugin(),
mongoOperationsPlugin()
]) as ProductRepoWithPlugins;
// 4. Now TypeScript provides full autocomplete and type checking!
await repo.increment(productId, 'views', 1); // ✅ Autocomplete works
await repo.upsert({ sku: 'ABC' }, { name: 'Product' }); // ✅ Type-safe
await repo.pushToArray(productId, 'tags', 'featured'); // ✅ Validated
await repo.findBySku('ABC'); // ✅ Custom methods tooAvailable operations:
upsert(query, data, opts)- Create or find documentincrement(id, field, value, opts)- Atomically increment fielddecrement(id, field, value, opts)- Atomically decrement fieldpushToArray(id, field, value, opts)- Add to arraypullFromArray(id, field, value, opts)- Remove from arrayaddToSet(id, field, value, opts)- Add unique value to arraysetField(id, field, value, opts)- Set field valueunsetField(id, fields, opts)- Remove field(s)renameField(id, oldName, newName, opts)- Rename fieldmultiplyField(id, field, multiplier, opts)- Multiply numeric fieldsetMin(id, field, value, opts)- Set to min (if current > value)setMax(id, field, value, opts)- Set to max (if current < value)
Plugin Type Safety
Plugin methods are added at runtime. Use WithPlugins<TDoc, TRepo> for TypeScript autocomplete:
import type { WithPlugins } from '@classytic/mongokit';
class UserRepo extends Repository<IUser> {}
const repo = new UserRepo(Model, [
methodRegistryPlugin(),
mongoOperationsPlugin(),
// ... other plugins
]) as WithPlugins<IUser, UserRepo>;
// Full TypeScript autocomplete!
await repo.increment(id, 'views', 1);
await repo.restore(id);
await repo.invalidateCache(id);Individual plugin types: MongoOperationsMethods<T>, BatchOperationsMethods, AggregateHelpersMethods, SubdocumentMethods<T>, SoftDeleteMethods<T>, CacheMethods
Event System
repo.on('before:create', async (context) => {
context.data.processedAt = new Date();
});
repo.on('after:create', ({ context, result }) => {
console.log('Created:', result);
});
repo.on('error:create', ({ context, error }) => {
console.error('Failed:', error);
});Events: before:*, after:*, error:* for create, createMany, update, delete, getById, getByQuery, getAll, aggregatePaginate
Building REST APIs
MongoKit provides a complete toolkit for building REST APIs: QueryParser for request handling, JSON Schema generation for validation/docs, and IController interface for framework-agnostic controllers.
IController Interface
Framework-agnostic controller contract that works with Express, Fastify, Next.js, etc:
import type { IController, IRequestContext, IControllerResponse } from '@classytic/mongokit';
// IRequestContext - what your controller receives
interface IRequestContext {
query: Record<string, unknown>; // URL query params
body: Record<string, unknown>; // Request body
params: Record<string, string>; // Route params (:id)
user?: { id: string; role?: string }; // Auth user
context?: Record<string, unknown>; // Tenant ID, etc.
}
// IControllerResponse - what your controller returns
interface IControllerResponse<T> {
success: boolean;
data?: T;
error?: string;
status: number;
}
// IController - implement this interface
interface IController<TDoc> {
list(ctx: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
get(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
create(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
update(ctx: IRequestContext): Promise<IControllerResponse<TDoc>>;
delete(ctx: IRequestContext): Promise<IControllerResponse<{ message: string }>>;
}QueryParser
Converts HTTP query strings to MongoDB queries with built-in security:
import { QueryParser } from '@classytic/mongokit';
const parser = new QueryParser({
maxLimit: 100, // Prevent excessive queries
maxFilterDepth: 5, // Prevent nested injection
maxRegexLength: 100, // ReDoS protection
});
// Parse request query
const { filters, limit, page, sort, search } = parser.parse(req.query);Supported query patterns:
# Filtering
GET /users?status=active&role=admin
GET /users?age[gte]=18&age[lte]=65
GET /users?role[in]=admin,user
GET /users?email[contains]=@gmail.com
GET /users?name[regex]=^John
# Pagination
GET /users?page=2&limit=50
GET /users?after=eyJfaWQiOi...&limit=20 # Cursor-based
# Sorting
GET /users?sort=-createdAt,name
# Search (requires text index)
GET /users?search=john
# Simple populate
GET /posts?populate=author,category
# Advanced populate with options
GET /posts?populate[author][select]=name,email
GET /posts?populate[author][match][active]=true
GET /posts?populate[comments][limit]=10
GET /posts?populate[comments][sort]=-createdAt
GET /posts?populate[author][populate][department][select]=name # NestedSecurity features:
- Blocks
$where,$function,$accumulator,$exproperators - ReDoS protection for regex patterns
- Max filter depth enforcement
- Collection allowlists for lookups
- Populate path sanitization (blocks
$where,__proto__, etc.) - Max populate depth limit (default: 5)
Advanced Populate Options
QueryParser supports Mongoose populate options via URL query parameters:
import { QueryParser } from '@classytic/mongokit';
const parser = new QueryParser();
// Parse URL: /posts?populate[author][select]=name,email&populate[author][match][active]=true
const parsed = parser.parse(req.query);
// Use with Repository
const posts = await postRepo.getAll(
{ filters: parsed.filters, page: parsed.page, limit: parsed.limit },
{ populateOptions: parsed.populateOptions }
);Supported populate options:
| Option | URL Syntax | Description |
|---|---|---|
select |
populate[path][select]=field1,field2 |
Fields to include (space-separated in Mongoose) |
match |
populate[path][match][field]=value |
Filter populated documents |
limit |
populate[path][limit]=10 |
Limit number of populated docs |
sort |
populate[path][sort]=-createdAt |
Sort populated documents |
populate |
populate[path][populate][nested][select]=field |
Nested populate (max depth: 5) |
Example - Complex populate:
// URL: /posts?populate[author][select]=name,avatar&populate[comments][limit]=5&populate[comments][sort]=-createdAt&populate[comments][match][approved]=true
const parsed = parser.parse(req.query);
// parsed.populateOptions = [
// { path: 'author', select: 'name avatar' },
// { path: 'comments', match: { approved: true }, options: { limit: 5, sort: { createdAt: -1 } } }
// ]
// Simple string populate still works
// URL: /posts?populate=author,category
// parsed.populate = 'author,category'
// parsed.populateOptions = undefinedJSON Schema Generation
Auto-generate JSON schemas from Mongoose models for validation and OpenAPI docs:
import { buildCrudSchemasFromModel } from '@classytic/mongokit';
const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
fieldRules: {
organizationId: { immutable: true }, // Can't update after create
role: { systemManaged: true }, // Users can't set this
createdAt: { systemManaged: true },
},
strictAdditionalProperties: true, // Reject unknown fields
});
// Generated schemas:
// crudSchemas.createBody - POST body validation
// crudSchemas.updateBody - PATCH body validation
// crudSchemas.params - Route params (:id)
// crudSchemas.listQuery - GET query validationComplete Controller Example
import {
Repository,
QueryParser,
buildCrudSchemasFromModel,
type IController,
type IRequestContext,
type IControllerResponse,
} from '@classytic/mongokit';
class UserController implements IController<IUser> {
private repo = new Repository(UserModel);
private parser = new QueryParser({ maxLimit: 100 });
async list(ctx: IRequestContext): Promise<IControllerResponse> {
const { filters, limit, page, sort } = this.parser.parse(ctx.query);
// Inject tenant filter
if (ctx.context?.organizationId) {
filters.organizationId = ctx.context.organizationId;
}
const result = await this.repo.getAll({ filters, limit, page, sort });
return { success: true, data: result, status: 200 };
}
async get(ctx: IRequestContext): Promise<IControllerResponse> {
const doc = await this.repo.getById(ctx.params.id);
return { success: true, data: doc, status: 200 };
}
async create(ctx: IRequestContext): Promise<IControllerResponse> {
const doc = await this.repo.create(ctx.body);
return { success: true, data: doc, status: 201 };
}
async update(ctx: IRequestContext): Promise<IControllerResponse> {
const doc = await this.repo.update(ctx.params.id, ctx.body);
return { success: true, data: doc, status: 200 };
}
async delete(ctx: IRequestContext): Promise<IControllerResponse> {
await this.repo.delete(ctx.params.id);
return { success: true, data: { message: 'Deleted' }, status: 200 };
}
}Fastify Integration
import { buildCrudSchemasFromModel } from '@classytic/mongokit';
const controller = new UserController();
const { crudSchemas } = buildCrudSchemasFromModel(UserModel);
// Routes with auto-validation and OpenAPI docs
fastify.get('/users', { schema: { querystring: crudSchemas.listQuery } }, async (req, reply) => {
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
const response = await controller.list(ctx);
return reply.status(response.status).send(response);
});
fastify.post('/users', { schema: { body: crudSchemas.createBody } }, async (req, reply) => {
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
const response = await controller.create(ctx);
return reply.status(response.status).send(response);
});
fastify.get('/users/:id', { schema: { params: crudSchemas.params } }, async (req, reply) => {
const ctx = { query: {}, body: {}, params: req.params, user: req.user };
const response = await controller.get(ctx);
return reply.status(response.status).send(response);
});Express Integration
const controller = new UserController();
app.get('/users', async (req, res) => {
const ctx = { query: req.query, body: {}, params: {}, user: req.user };
const response = await controller.list(ctx);
res.status(response.status).json(response);
});
app.post('/users', async (req, res) => {
const ctx = { query: {}, body: req.body, params: {}, user: req.user };
const response = await controller.create(ctx);
res.status(response.status).json(response);
});TypeScript
import { Repository, OffsetPaginationResult, KeysetPaginationResult } from '@classytic/mongokit';
interface IUser extends Document {
name: string;
email: string;
}
const repo = new Repository<IUser>(UserModel);
const result = await repo.getAll({ page: 1, limit: 20 });
// Discriminated union - TypeScript knows the type
if (result.method === 'offset') {
console.log(result.total, result.pages); // Available
}
if (result.method === 'keyset') {
console.log(result.next, result.hasMore); // Available
}Extending Repository
Create custom repository classes with domain-specific methods:
import { Repository, softDeletePlugin, timestampPlugin } from '@classytic/mongokit';
import UserModel, { IUser } from './models/User.js';
class UserRepository extends Repository<IUser> {
constructor() {
super(UserModel, [
timestampPlugin(),
softDeletePlugin()
], {
defaultLimit: 20
});
}
// Custom domain methods
async findByEmail(email: string) {
return this.getByQuery({ email });
}
async findActiveUsers() {
return this.getAll({
filters: { status: 'active' },
sort: { createdAt: -1 }
});
}
async deactivate(id: string) {
return this.update(id, { status: 'inactive', deactivatedAt: new Date() });
}
}
// Usage
const userRepo = new UserRepository();
const user = await userRepo.findByEmail('john@example.com');Overriding Methods
class AuditedUserRepository extends Repository<IUser> {
constructor() {
super(UserModel);
}
// Override create to add audit trail
async create(data: Partial<IUser>, options = {}) {
const result = await super.create({
...data,
createdBy: getCurrentUserId()
}, options);
await auditLog('user.created', result._id);
return result;
}
}Factory Function
For simple cases without custom methods:
import { createRepository, timestampPlugin } from '@classytic/mongokit';
const userRepo = createRepository(UserModel, [timestampPlugin()], {
defaultLimit: 20
});No Breaking Changes
Extending Repository works exactly the same with Mongoose 8 and 9. The package:
- Uses its own event system (not Mongoose middleware)
- Defines its own
FilterQuerytype (unaffected by Mongoose 9 rename) - Properly gates update pipelines (safe for Mongoose 9's stricter defaults)
- All 194 tests pass on both Mongoose 8 and 9
License
MIT