Package Exports
- @classytic/mongokit
- @classytic/mongokit/actions
- @classytic/mongokit/pagination
- @classytic/mongokit/plugins
- @classytic/mongokit/utils
Readme
@classytic/mongokit
Production-grade MongoDB repositories with zero external dependencies
Works with: Express • Fastify • NestJS • Next.js • Koa • Hapi • Serverless
- ✅ Zero external dependencies (only Mongoose peer dependency)
- ✅ Smart pagination - auto-detects offset vs cursor-based
- ✅ HTTP utilities - query parser & schema generator for controllers
- ✅ Event-driven hooks for every operation
- ✅ Plugin architecture for reusable behaviors
- ✅ TypeScript first-class support with discriminated unions
- ✅ Optional caching - Redis/Memcached with auto-invalidation
- ✅ Battle-tested in production with 182 passing tests
📦 Installation
npm install @classytic/mongokit mongoosePeer Dependencies:
mongoose ^8.0.0 || ^9.0.0(supports both Mongoose 8 and 9)
Available imports:
import { MongooseRepository } from '@classytic/mongokit'; // Core repository
import { queryParser, buildCrudSchemasFromModel } from '@classytic/mongokit/utils'; // HTTP utilitiesThat's it. No additional pagination, validation, or query parsing libraries needed.
🚀 Quick Start
Basic Usage
import { Repository } from '@classytic/mongokit';
import UserModel from './models/User.js';
class UserRepository extends Repository {
constructor() {
super(UserModel);
}
}
const userRepo = new UserRepository();
// Create
const user = await userRepo.create({
name: 'John',
email: 'john@example.com'
});
// Read - auto-detects pagination mode
const users = await userRepo.getAll({
page: 1,
limit: 20
});
// Update
await userRepo.update('user-id', { name: 'Jane' });
// Delete
await userRepo.delete('user-id');Unified Pagination - One Method, Two Modes
The getAll() method automatically detects whether you want offset (page-based) or keyset (cursor-based) pagination:
// Offset pagination (page-based) - for admin dashboards
const page1 = await userRepo.getAll({
page: 1,
limit: 20,
filters: { status: 'active' },
sort: { createdAt: -1 }
});
// → { method: 'offset', docs: [...], total: 1523, pages: 77, page: 1, ... }
// Keyset pagination (cursor-based) - for infinite scroll
const stream1 = await userRepo.getAll({
sort: { createdAt: -1 },
limit: 20
});
// → { method: 'keyset', docs: [...], hasMore: true, next: 'eyJ2IjoxLCJ0Ij...' }
// Load next page with cursor
const stream2 = await userRepo.getAll({
after: stream1.next,
sort: { createdAt: -1 },
limit: 20
});Auto-detection logic:
- If
pageparameter provided → offset mode - If
afterorcursorparameter provided → keyset mode - If explicit
sortprovided withoutpage→ keyset mode (first page) - Otherwise → offset mode (default, page 1)
🎯 Pagination Modes Explained
Offset Pagination (Page-Based)
Best for: Admin dashboards, page numbers, showing total counts
const result = await userRepo.getAll({
page: 1,
limit: 20,
filters: { status: 'active' },
sort: { createdAt: -1 }
});
console.log(result.method); // 'offset'
console.log(result.docs); // Array of documents
console.log(result.total); // Total count (e.g., 1523)
console.log(result.pages); // Total pages (e.g., 77)
console.log(result.page); // Current page (1)
console.log(result.hasNext); // true
console.log(result.hasPrev); // falsePerformance:
- Time complexity: O(n) where n = page × limit
- Works great for small-medium datasets
- Warning triggered for pages > 100
Keyset Pagination (Cursor-Based)
Best for: Infinite scroll, real-time feeds, large datasets
const result = await userRepo.getAll({
sort: { createdAt: -1 },
limit: 20
});
console.log(result.method); // 'keyset'
console.log(result.docs); // Array of documents
console.log(result.hasMore); // true
console.log(result.next); // 'eyJ2IjoxLCJ0IjoiZGF0ZSIsInYiO...'
// Load next page
const next = await userRepo.getAll({
after: result.next,
sort: { createdAt: -1 },
limit: 20
});Performance:
- Time complexity: O(1) regardless of position
- Requires compound index:
{ sortField: 1, _id: 1 } - Ideal for millions of documents
Required Index:
// For sort: { createdAt: -1 }
PostSchema.index({ createdAt: -1, _id: -1 });
// For sort: { publishedAt: -1, views: -1 }
PostSchema.index({ publishedAt: -1, views: -1, _id: -1 });💡 Real-World Examples
Text Search + Infinite Scroll
// Define schema with text index
const PostSchema = new mongoose.Schema({
title: String,
content: String,
publishedAt: { type: Date, default: Date.now }
});
PostSchema.index({ title: 'text', content: 'text' });
PostSchema.index({ publishedAt: -1, _id: -1 });
// Search and paginate
const postRepo = new Repository(PostModel);
const page1 = await postRepo.getAll({
search: 'JavaScript',
sort: { publishedAt: -1 },
limit: 20
});
// → Returns first 20 posts matching "JavaScript"
// User scrolls down - load more
const page2 = await postRepo.getAll({
after: page1.next,
search: 'JavaScript',
sort: { publishedAt: -1 },
limit: 20
});
// → Next 20 posts with same search queryAdmin Dashboard with Filters
const result = await userRepo.getAll({
page: req.query.page || 1,
limit: 50,
filters: {
status: 'active',
role: { $in: ['admin', 'moderator'] }
},
sort: { lastLoginAt: -1 }
});
res.json({
users: result.docs,
pagination: {
page: result.page,
pages: result.pages,
total: result.total,
hasNext: result.hasNext,
hasPrev: result.hasPrev
}
});Multi-Tenant Applications
class TenantUserRepository extends Repository {
constructor() {
super(UserModel, [], {
defaultLimit: 20,
maxLimit: 100
});
}
async getAllForTenant(organizationId, params = {}) {
return this.getAll({
...params,
filters: {
organizationId,
...params.filters
}
});
}
}
// Use it
const users = await tenantRepo.getAllForTenant('org-123', {
page: 1,
limit: 50,
filters: { status: 'active' }
});Switching Between Modes Seamlessly
// Admin view - needs page numbers and total count
const adminView = await postRepo.getAll({
page: 1,
limit: 20,
sort: { createdAt: -1 }
});
// → method: 'offset', total: 1523, pages: 77
// Public feed - infinite scroll
const feedView = await postRepo.getAll({
sort: { createdAt: -1 },
limit: 20
});
// → method: 'keyset', next: 'eyJ2IjoxLC...'
// Both return same first 20 results!🌐 HTTP Utilities for Controllers & Routes
MongoKit provides utilities to quickly build production-ready controllers and routes for Express, Fastify, NestJS, and other frameworks.
Query Parser
Parse HTTP query strings into MongoDB filters automatically:
import { queryParser } from '@classytic/mongokit/utils';
// Express/Fastify route
app.get('/users', async (req, res) => {
const { filters, limit, page, sort } = queryParser.parseQuery(req.query);
const result = await userRepo.getAll({ filters, limit, page, sort });
res.json(result);
});Supported query patterns:
# Simple filtering
GET /users?email=john@example.com&role=admin
# Operators
GET /users?age[gte]=18&age[lte]=65 # Range queries
GET /users?email[contains]=gmail # Text search
GET /users?role[in]=admin,user # Multiple values
GET /users?status[ne]=deleted # Not equal
# Pagination
GET /users?page=2&limit=50 # Offset pagination
GET /users?after=eyJfaWQiOiI2M... # Cursor pagination
# Sorting
GET /users?sort=-createdAt,name # Multi-field sort (- = descending)
# Combined
GET /users?role=admin&createdAt[gte]=2024-01-01&sort=-createdAt&limit=20Schema Generator (Fastify/OpenAPI)
Generate JSON schemas from Mongoose models with field rules:
import { buildCrudSchemasFromModel } from '@classytic/mongokit/utils';
const { crudSchemas } = buildCrudSchemasFromModel(UserModel, {
strictAdditionalProperties: true, // Reject unknown fields
fieldRules: {
organizationId: { immutable: true }, // Cannot be updated
status: { systemManaged: true }, // Omitted from create/update
email: { optional: false }, // Required field
},
create: {
omitFields: ['verifiedAt'], // Custom omissions
},
});
// Use in Fastify routes
fastify.post('/users', {
schema: crudSchemas.create,
}, async (request, reply) => {
const user = await userRepo.create(request.body);
return reply.status(201).send(user);
});
fastify.get('/users', {
schema: crudSchemas.list,
}, async (request, reply) => {
const { filters, limit, page, sort } = queryParser.parseQuery(request.query);
const result = await userRepo.getAll({ filters, limit, page, sort });
return reply.send(result);
});Generated schemas:
crudSchemas.create- POST validation (body only)crudSchemas.update- PATCH validation (body + params)crudSchemas.get- GET by ID validation (params)crudSchemas.list- GET list validation (query)crudSchemas.remove- DELETE validation (params)
Field Rules:
immutable- Field cannot be updated after creation (omitted from update schema)systemManaged- System-only field (omitted from both create and update schemas)optional- Remove from required array
See full example: examples/fastify-controller-example.js
📘 Complete API Reference
CRUD Operations
| Method | Description | Example |
|---|---|---|
create(data, opts) |
Create single document | repo.create({ name: 'John' }) |
createMany(data[], opts) |
Create multiple documents | repo.createMany([{...}, {...}]) |
getById(id, opts) |
Find by ID | repo.getById('123') |
getByQuery(query, opts) |
Find one by query | repo.getByQuery({ email: 'a@b.com' }) |
getAll(params, opts) |
Paginated list | repo.getAll({ page: 1, limit: 20 }) |
getOrCreate(query, data, opts) |
Find or create | repo.getOrCreate({ email }, { email, name }) |
update(id, data, opts) |
Update document | repo.update('123', { name: 'Jane' }) |
delete(id, opts) |
Delete document | repo.delete('123') |
count(query, opts) |
Count documents | repo.count({ status: 'active' }) |
exists(query, opts) |
Check existence | repo.exists({ email: 'a@b.com' }) |
getAll() Parameters
await repo.getAll({
// Pagination mode (auto-detected)
page: 1, // Offset mode: page number
after: 'cursor...', // Keyset mode: cursor token
cursor: 'cursor...', // Alias for 'after'
// Common parameters
limit: 20, // Documents per page
filters: { ... }, // MongoDB query filters
sort: { createdAt: -1 }, // Sort specification
search: 'keyword', // Full-text search (requires text index)
// Additional options (in options parameter)
select: 'name email', // Field projection
populate: 'author', // Population
lean: true, // Return plain objects (default: true)
session: session // Transaction session
});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: [
{ $match: { status: 'active' } },
{ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }
],
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 if no errors, auto-rollbacks on errors
});🔧 Configuration
Pagination Configuration
import { Repository } from '@classytic/mongokit';
const userRepo = new Repository(UserModel, [], {
defaultLimit: 20, // Default documents per page
maxLimit: 100, // Maximum allowed limit
maxPage: 10000, // Maximum page number (offset mode)
deepPageThreshold: 100, // Warn when page exceeds this
useEstimatedCount: false, // Use estimatedDocumentCount() for speed
cursorVersion: 1 // Cursor format version
});Estimated Counts (for large collections)
For collections with millions of documents, counting can be slow. Use estimated counts:
const repo = new Repository(UserModel, [], {
useEstimatedCount: true // O(1) metadata lookup instead of O(n) count
});
const result = await repo.getAll({ page: 1, limit: 20 });
// Uses estimatedDocumentCount() - instant but approximateNote: Estimated counts ignore filters and sessions by design (reads metadata, not documents).
📊 Indexing Guide
Critical: MongoDB only auto-indexes _id. You must create indexes for efficient pagination.
Single-Tenant Applications
const PostSchema = new mongoose.Schema({
title: String,
publishedAt: { type: Date, default: Date.now }
});
// Required for keyset pagination
PostSchema.index({ publishedAt: -1, _id: -1 });
// ^^^^^^^^^^^^^^ ^^^^^^
// Sort field Tie-breakerMulti-Tenant Applications
const UserSchema = new mongoose.Schema({
organizationId: String,
email: String,
createdAt: { type: Date, default: Date.now }
});
// Required for multi-tenant keyset pagination
UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^
// Tenant filter Sort field Tie-breakerCommon Index Patterns
// Basic sorting
Schema.index({ createdAt: -1, _id: -1 });
// Multi-tenant
Schema.index({ tenantId: 1, createdAt: -1, _id: -1 });
// Multi-tenant + status filter
Schema.index({ tenantId: 1, status: 1, createdAt: -1, _id: -1 });
// Text search
Schema.index({ title: 'text', content: 'text' });
Schema.index({ createdAt: -1, _id: -1 }); // Still need this for sorting
// Multi-field sort
Schema.index({ priority: -1, createdAt: -1, _id: -1 });Performance Impact
| Scenario | Without Index | With Index |
|---|---|---|
| 10K docs | ~50ms | ~5ms |
| 1M docs | ~5000ms | ~5ms |
| 100M docs | timeout | ~5ms |
Rule: Index = (tenant_field +) sort_field + _id
🔌 Built-in Plugins
Field Filtering (Role-based Access)
Control which fields are visible based on user roles:
import { Repository, fieldFilterPlugin } from '@classytic/mongokit';
const fieldPreset = {
public: ['id', 'name', 'email'],
authenticated: ['phone', 'address'],
admin: ['createdAt', 'updatedAt', 'internalNotes']
};
class UserRepository extends Repository {
constructor() {
super(UserModel, [fieldFilterPlugin(fieldPreset)]);
}
}Validation Chain
Add custom validation rules:
import {
Repository,
validationChainPlugin,
requireField,
uniqueField,
immutableField
} from '@classytic/mongokit';
class UserRepository extends Repository {
constructor() {
super(UserModel, [
validationChainPlugin([
requireField('email', ['create']),
uniqueField('email', 'Email already exists'),
immutableField('userId')
])
]);
}
}Soft Delete
Mark records as deleted without actually removing them:
import { Repository, softDeletePlugin } from '@classytic/mongokit';
class UserRepository extends Repository {
constructor() {
super(UserModel, [softDeletePlugin({ deletedField: 'deletedAt' })]);
}
}
// repo.delete(id) → marks as deleted instead of removing
// repo.getAll() → excludes deleted records
// repo.getAll({ includeDeleted: true }) → includes deletedAudit Logging
Log all create, update, and delete operations:
import { Repository, auditLogPlugin } from '@classytic/mongokit';
import logger from './logger.js';
class UserRepository extends Repository {
constructor() {
super(UserModel, [auditLogPlugin(logger)]);
}
}
// All CUD operations automatically loggedCaching (Redis, Memcached, or In-Memory)
Add caching with automatic invalidation on mutations:
import { Repository, cachePlugin, createMemoryCache } from '@classytic/mongokit';
const userRepo = new Repository(UserModel, [
cachePlugin({
adapter: createMemoryCache(), // or your Redis adapter
ttl: 60, // 60 seconds default
byIdTtl: 300, // 5 min for getById
queryTtl: 30, // 30s for lists
})
]);
// Reads are cached automatically
const user = await userRepo.getById(id); // cached on second call
// Skip cache for fresh data
const fresh = await userRepo.getById(id, { skipCache: true });
// Mutations auto-invalidate cache
await userRepo.update(id, { name: 'New' });
// Manual invalidation (microservices)
await userRepo.invalidateCache(id); // single doc
await userRepo.invalidateAllCache(); // full modelRedis 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 by pattern */ }
};More Plugins
timestampPlugin()- Auto-managecreatedAt/updatedAtmongoOperationsPlugin()- Addsincrement,pushToArray,upsert, etc.batchOperationsPlugin()- AddsupdateMany,deleteManyaggregateHelpersPlugin()- AddsgroupBy,sum,average, etc.subdocumentPlugin()- Manage subdocument arrays easily
🎨 Event System
Every operation emits lifecycle events:
repo.on('before:create', async (context) => {
console.log('About to create:', context.data);
// Modify context.data if needed
context.data.processedAt = new Date();
});
repo.on('after:create', ({ context, result }) => {
console.log('Created:', result);
// Send notification, update cache, etc.
});
repo.on('error:create', ({ context, error }) => {
console.error('Failed to create:', error);
// Log error, send alert, etc.
});Available Events:
before:create,after:create,error:createbefore:update,after:update,error:updatebefore:delete,after:delete,error:deletebefore:createMany,after:createMany,error:createManybefore:getAll,before:getById,before:getByQuery
🎯 Custom Plugins
Create your own plugins:
export const timestampPlugin = () => ({
name: 'timestamp',
apply(repo) {
repo.on('before:create', (context) => {
context.data.createdAt = new Date();
context.data.updatedAt = new Date();
});
repo.on('before:update', (context) => {
context.data.updatedAt = new Date();
});
}
});
// Use it
class UserRepository extends Repository {
constructor() {
super(UserModel, [timestampPlugin()]);
}
}Combining Multiple Plugins
import {
Repository,
softDeletePlugin,
auditLogPlugin,
fieldFilterPlugin
} from '@classytic/mongokit';
class UserRepository extends Repository {
constructor() {
super(UserModel, [
softDeletePlugin(),
auditLogPlugin(logger),
fieldFilterPlugin(userFieldPreset)
]);
}
}📚 TypeScript Support
Full TypeScript support with discriminated unions:
import {
Repository,
OffsetPaginationResult,
KeysetPaginationResult
} from '@classytic/mongokit';
import { Document } from 'mongoose';
interface IUser extends Document {
name: string;
email: string;
status: 'active' | 'inactive';
}
class UserRepository extends Repository {
constructor() {
super(UserModel);
}
async findActive(): Promise<IUser[]> {
const result = await this.getAll({
filters: { status: 'active' },
page: 1,
limit: 50
});
// TypeScript knows result is OffsetPaginationResult
if (result.method === 'offset') {
console.log(result.total); // ✅ Type-safe
console.log(result.pages); // ✅ Type-safe
// console.log(result.next); // ❌ Type error
}
return result.docs;
}
async getFeed(): Promise<IUser[]> {
const result = await this.getAll({
sort: { createdAt: -1 },
limit: 20
});
// TypeScript knows result is KeysetPaginationResult
if (result.method === 'keyset') {
console.log(result.next); // ✅ Type-safe
console.log(result.hasMore); // ✅ Type-safe
// console.log(result.total); // ❌ Type error
}
return result.docs;
}
}Import Types
import type {
PaginationConfig,
OffsetPaginationOptions,
KeysetPaginationOptions,
AggregatePaginationOptions,
OffsetPaginationResult,
KeysetPaginationResult,
AggregatePaginationResult
} from '@classytic/mongokit';🏎️ Performance Tips
1. Use Keyset Pagination for Large Datasets
// ❌ Slow for large datasets (millions of documents)
await repo.getAll({ page: 1000, limit: 50 }); // O(50000)
// ✅ Fast regardless of position
await repo.getAll({ after: cursor, limit: 50 }); // O(1)2. Create Required Indexes
IMPORTANT: MongoDB only auto-indexes _id. You must manually create indexes for pagination.
// ✅ Single-Tenant: Sort field + _id
PostSchema.index({ createdAt: -1, _id: -1 });
// ✅ Multi-Tenant: Tenant field + Sort field + _id
UserSchema.index({ organizationId: 1, createdAt: -1, _id: -1 });
// ✅ Text Search: Text index
PostSchema.index({ title: 'text', content: 'text' });Without indexes = slow (full collection scan) With indexes = fast (O(1) index seek)
3. Use Estimated Counts for Large Collections
const repo = new Repository(UserModel, [], {
useEstimatedCount: true // Instant counts for >10M documents
});4. Use Lean Queries (Enabled by Default)
// Lean is true by default - returns plain objects
const result = await repo.getAll({ page: 1 });
// Disable for Mongoose documents (if you need methods)
const result = await repo.getAll({ page: 1 }, { lean: false });5. Limit $facet Results in Aggregation
// ⚠️ Warning triggered automatically at limit > 1000
await repo.aggregatePaginate({
pipeline: [...],
limit: 2000 // Warning: $facet results must be <16MB
});🔄 Migration Guide
From mongoose-paginate-v2
// Before
import mongoosePaginate from 'mongoose-paginate-v2';
UserSchema.plugin(mongoosePaginate);
const result = await UserModel.paginate({ status: 'active' }, { page: 1, limit: 10 });
// After
import { Repository } from '@classytic/mongokit';
const userRepo = new Repository(UserModel);
const result = await userRepo.getAll({
filters: { status: 'active' },
page: 1,
limit: 10
});From Prisma
// Before (Prisma)
const users = await prisma.user.findMany({
where: { status: 'active' },
skip: 20,
take: 10
});
// After (MongoKit)
const result = await userRepo.getAll({
filters: { status: 'active' },
page: 3,
limit: 10
});
const users = result.docs;From TypeORM
// Before (TypeORM)
const [users, total] = await userRepository.findAndCount({
where: { status: 'active' },
skip: 20,
take: 10
});
// After (MongoKit)
const result = await userRepo.getAll({
filters: { status: 'active' },
page: 3,
limit: 10
});
const users = result.docs;
const total = result.total;🌟 Why MongoKit?
vs. Mongoose Directly
- ✅ Consistent API across all models
- ✅ Built-in pagination (offset + cursor) with zero dependencies
- ✅ Multi-tenancy without repetitive code
- ✅ Event hooks for cross-cutting concerns
- ✅ Plugin system for reusable behaviors
vs. mongoose-paginate-v2
- ✅ Zero external dependencies (no mongoose-paginate-v2 needed)
- ✅ Cursor-based pagination for infinite scroll
- ✅ Unified API that auto-detects pagination mode
- ✅ Native MongoDB implementation ($facet, cursors)
- ✅ Better TypeScript support
vs. TypeORM / Prisma
- ✅ Lighter weight (works with Mongoose)
- ✅ Event-driven architecture
- ✅ More flexible plugin system
- ✅ No migration needed if using Mongoose
- ✅ Framework-agnostic
vs. Raw Repository Pattern
- ✅ Battle-tested implementation (68 passing tests)
- ✅ 11 built-in plugins ready to use
- ✅ Comprehensive documentation
- ✅ TypeScript discriminated unions
- ✅ Active maintenance
🧪 Testing
npm testTest Coverage:
- 184 tests (182 passing, 2 skipped - require replica set)
- CRUD operations
- Offset pagination
- Keyset pagination
- Aggregation pagination
- Caching (hit/miss, invalidation)
- Multi-tenancy
- Text search + infinite scroll
- Real-world scenarios
📖 Examples
Check out the examples directory for:
- Express REST API
- Fastify REST API
- Next.js API routes
- Multi-tenant SaaS
- Infinite scroll feed
- Admin dashboard
🤝 Contributing
Contributions are welcome! Please check out our contributing guide.
📄 License
MIT © Classytic
🔗 Links
Built with ❤️ by developers, for developers.
Zero dependencies. Zero compromises. Production-grade MongoDB pagination.