JSPM

  • Created
  • Published
  • Downloads 1336
  • Score
    100M100P100Q101825F
  • License MIT

Production-grade MongoDB repositories with zero dependencies - smart pagination, events, and plugins

Package Exports

  • @classytic/mongokit
  • @classytic/mongokit/actions
  • @classytic/mongokit/pagination
  • @classytic/mongokit/plugins
  • @classytic/mongokit/utils

Readme

@classytic/mongokit

Test npm version License: MIT

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 mongoose

Peer 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 utilities

That'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:

  1. If page parameter provided → offset mode
  2. If after or cursor parameter provided → keyset mode
  3. If explicit sort provided without pagekeyset mode (first page)
  4. 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);   // false

Performance:

  • 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 query

Admin 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=20

Schema 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 approximate

Note: 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-breaker

Multi-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-breaker

Common 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 deleted

Audit 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 logged

Caching (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 model

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 by pattern */ }
};

More Plugins

  • timestampPlugin() - Auto-manage createdAt/updatedAt
  • mongoOperationsPlugin() - Adds increment, pushToArray, upsert, etc.
  • batchOperationsPlugin() - Adds updateMany, deleteMany
  • aggregateHelpersPlugin() - Adds groupBy, 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:create
  • before:update, after:update, error:update
  • before:delete, after:delete, error:delete
  • before:createMany, after:createMany, error:createMany
  • before: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 test

Test 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



Built with ❤️ by developers, for developers.

Zero dependencies. Zero compromises. Production-grade MongoDB pagination.