Package Exports
- @classytic/mongokit
- @classytic/mongokit/actions
- @classytic/mongokit/ai
- @classytic/mongokit/pagination
- @classytic/mongokit/plugins
- @classytic/mongokit/query/primitives/coercion
- @classytic/mongokit/query/primitives/geo
- @classytic/mongokit/query/primitives/indexes
- @classytic/mongokit/utils
Readme
@classytic/mongokit
Production-grade MongoDB repository pattern for Node.js. Zero runtime deps, Mongoose as a peer.
npm install @classytic/mongokit mongooseRequires Mongoose >=9.4.1, Node.js >=22.
Quick start
import { Repository } from '@classytic/mongokit';
import UserModel from './models/User.js';
const userRepo = new Repository(UserModel);
const user = await userRepo.create({ name: 'John', email: 'john@example.com' });
const page = await userRepo.getAll({ page: 1, limit: 20, filters: { status: 'active' } });
await userRepo.update(user._id, { name: 'Jane' });
await userRepo.delete(user._id);Every Repository method is event-driven, pluggable, and returns raw Mongoose documents — no custom envelopes.
Core concepts
Repository
A Repository<TDoc> wraps a Mongoose model and exposes a small, uniform CRUD surface:
- Reads:
getById,getByQuery,getOne,getAll,findAll,count,exists,distinct,aggregate,aggregatePaginate,lookupPopulate - Writes:
create,createMany,update,updateMany(via plugin),delete,deleteMany(via plugin),bulkWrite(via plugin) - Restore / trash bin: via
softDeletePlugin
Extend it by subclassing; add domain verbs on the subclass. Do not wrap it in a service layer.
class InvoiceRepository extends Repository<InvoiceDoc> {
async markPaid(id: string, ctx: RepositoryContext) {
return this.update(id, { status: 'paid', paidAt: new Date() }, ctx);
}
}Plugins
Plugins register hooks and/or methods on a repository. Order matters — they run at declared priorities:
import {
Repository,
methodRegistryPlugin,
batchOperationsPlugin,
multiTenantPlugin,
softDeletePlugin,
cachePlugin,
} from '@classytic/mongokit';
const repo = new Repository<Invoice>(InvoiceModel, [
methodRegistryPlugin(),
batchOperationsPlugin(),
multiTenantPlugin({ tenantField: 'organizationId' }), // POLICY (100)
softDeletePlugin({ deletedField: 'deletedAt' }), // POLICY (100)
cachePlugin({ adapter: redisAdapter }), // CACHE (200)
]);Hook priorities ensure deterministic ordering: POLICY (100) → CACHE (200) → OBSERVABILITY (300) → DEFAULT (500).
Events
Every operation emits before:*, after:*, error:*:
repo.on('before:delete', (ctx) => { /* mutate ctx, throw to veto */ });
repo.on('after:create', ({ context, result }) => { /* audit, notify */ });The before:* hook receives the context directly; after:* and error:* receive { context, result | error }.
Pagination
getAll supports offset, keyset (cursor), and aggregate pagination. Pass mode: 'offset' | 'keyset' explicitly or let it auto-detect.
// Offset — dashboards, admin panels
await repo.getAll({ mode: 'offset', page: 1, limit: 20, sort: { createdAt: -1 } });
// → { method: 'offset', docs, total, pages, hasNext, hasPrev }
// Keyset — feeds, infinite scroll
const p1 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, limit: 20 });
const p2 = await repo.getAll({ mode: 'keyset', sort: { createdAt: -1 }, after: p1.next });
// → { method: 'keyset', docs, hasMore, next }Keyset pagination with filters + sort warns once if no matching schema-declared compound index exists. Silent in NODE_ENV=test. Route warnings via configureLogger({ warn }).
Delete semantics
delete(id) respects the plugin stack by default — soft when softDeletePlugin is wired, physical otherwise. Pass mode: 'hard' for GDPR / admin cleanup while keeping every policy hook firing:
// Default — soft when plugin wired
await repo.delete(userId, { organizationId });
// Hard — physical delete, audit + tenant scoping still enforced
await repo.delete(userId, { organizationId, mode: 'hard' });
// Bulk hard delete (requires batchOperationsPlugin)
await repo.deleteMany(
{ createdAt: { $lt: cutoff } },
{ organizationId, mode: 'hard' },
);Never drop to repo.Model.deleteOne — that bypasses every hook you wired.
Transactions
Two entry points, identical semantics, shared retry/fallback logic:
import { withTransaction } from '@classytic/mongokit';
// Cross-repo — pass a Mongoose connection
await withTransaction(mongoose.connection, async (session) => {
const order = await orderRepo.create(data, { session });
await ledgerRepo.create({ orderId: order._id, amount }, { session });
return order;
});
// Single-repo convenience
await orderRepo.withTransaction(async (session) => {
return orderRepo.create(data, { session });
});Both auto-retry on TransientTransactionError / UnknownTransactionCommitResult. Pass { allowFallback: true, onFallback } to run the callback non-transactionally on standalone MongoDB (dev).
Outbox pattern — compose, don't plugin
mongokit does not ship an outboxPlugin. It doesn't need to.
The only thing outbox requires is the ability to write an event row in the same MongoDB session as the business write — otherwise a crash between the two writes loses the event. Mongokit's hook system already hands you context.session at the exact moment of the write, so hosts can wire outbox with a ~60-line recipe that composes:
- mongokit's hooks (
before:create/before:update/before:delete) for session-bound writes - arc's
EventTransport(Memory / Redis / Kafka / etc.) for delivery - a Mongo collection (with a TTL index on
deliveredAt) as the outbox store
See tests/_shared/outbox-recipe.ts for the full reference implementation and tests/outbox-recipe.test.ts for end-to-end coverage of the pattern (session threading, FIFO relay, failure retry, shouldEnqueue / enrichMeta extension points).
Sketch:
// host: src/outbox/wire.ts
import { wireOutbox, MongoOutboxStore } from './outbox-recipe.js';
import { RedisEventTransport } from '@classytic/arc/events';
const store = new MongoOutboxStore({ connection: mongoose.connection, name: 'outbox' });
const transport = new RedisEventTransport({ url: process.env.REDIS_URL });
wireOutbox({
repos: {
'catalog:product': catalog.repositories.product,
'revenue:transaction': revenue.repositories.transaction,
'order:order': order.repositories.order,
},
store,
// optional: skip internal audit repos, add tenant/correlation meta, etc.
shouldEnqueue: ({ resource }) => resource !== 'audit:log',
enrichMeta: (ctx) => ({ correlationId: ctx.correlationId as string | undefined }),
});
// relay worker — runs on app startup
setInterval(async () => {
const pending = await store.getPending(100);
for (const event of pending) {
try {
await transport.publish(event);
await store.acknowledge(event.meta.id);
} catch {
break; // retry next tick
}
}
}, 1000);That's the entire outbox. No plugin, no custom base class, no opinions baked into the data layer. The host decides:
- Which repos emit events (
wireOutbox.repos) - What the event type naming convention is (map keys →
${resource}.created) - Which transport to publish to (any
EventTransportimplementation) - When the relay runs (
setInterval, BullMQ, scheduled worker…) - How to enrich meta (AsyncLocalStorage, request ctx, tenant keys)
mongokit's job stops at "the hook has context.session." Everything else is composition.
Built-in plugins
| Plugin | Purpose |
|---|---|
methodRegistryPlugin |
Prerequisite for plugins that attach new methods |
batchOperationsPlugin |
updateMany, deleteMany, bulkWrite with hook support |
multiTenantPlugin |
Inject tenant scope at POLICY priority — impossible to forget |
softDeletePlugin |
deletedAt / custom field, restore, getDeleted, TTL, before:restore / after:restore hooks |
cascadePlugin |
Cascade delete; prefer { repo: targetRepo, foreignKey, softDelete? } to route through the target's hook pipeline |
customIdPlugin |
Stripe-style prefixed public IDs (txn_a7b3xk9m) on top of _id |
cachePlugin |
Pluggable adapter (memory, Redis); list-cache versioning for multi-pod correctness |
auditLogPlugin / auditTrailPlugin |
Who/when/what capture to a sibling collection |
observabilityPlugin |
Metric hooks for OpenTelemetry / Prometheus bridges |
timestampPlugin |
createdAt / updatedAt management |
validationChainPlugin |
Layered sync + async validators |
fieldFilterPlugin |
Role-based field visibility on reads |
subdocumentPlugin |
Helpers for nested array document CRUD |
mongoOperationsPlugin |
Low-level Mongo helpers ($inc, $push, etc.) with hooks |
aggregateHelpersPlugin |
Common aggregation builders |
elasticPlugin |
Mirror writes to Elasticsearch |
Each plugin is tree-shakeable. Import only what you use.
QueryParser (URL → filter)
Turn Express / Fastify query strings into sanitized Mongo filters + sort + pagination.
import { QueryParser } from '@classytic/mongokit';
const parser = new QueryParser({
schema: InvoiceModel.schema, // enables type coercion + geo/text detection
allowedFilterFields: ['status', 'total', 'customerId'],
allowedSortFields: ['createdAt', 'total'],
searchMode: 'auto', // 'text' | 'regex' | 'auto'
});
// GET /invoices?status=paid&total_gte=1000&sort=-createdAt&page=1&limit=20
const { filters, sort, page, limit, search } = parser.parse(req.query);
const result = await invoiceRepo.getAll({ filters, sort, page, limit, search });Supports URL operators: _gt/_gte/_lt/_lte/_ne/_in/_nin/_regex, geo ([near], [withinRadius], [geoWithin]), populate, and schema-aware coercion. ReDoS protection + allowlisted operators for hardening.
TypeScript
Full type safety for repository methods, plugin method combinations, events, and query contexts.
import type {
Repository,
RepositoryContext,
RepositoryEvent,
SoftDeleteMethods,
BatchOperationsMethods,
} from '@classytic/mongokit';
type InvoiceRepo = Repository<Invoice> &
SoftDeleteMethods<Invoice> &
BatchOperationsMethods;Subpath imports
Import pure primitives without pulling the full package surface:
import { extractSchemaIndexes } from '@classytic/mongokit/query/primitives/indexes';
import { parseGeoFilter } from '@classytic/mongokit/query/primitives/geo';
import { coerceFieldValue } from '@classytic/mongokit/query/primitives/coercion';Testing
Uses mongodb-memory-server by default; override via MONGODB_URI for a real replica set when running transaction tests locally.
npm test # full suite (~80 files, 1500+ tests)
npx vitest run tests/X.ts # single file while iteratingSee tests/ for real-world plugin composition examples (multi-tenant + soft-delete + cascade + audit).
Mongoose compatibility
Aligned with Mongoose 9.4.x. Earlier versions (8.x) are untested — pin a mongokit v3.x release if you need 8.x support.
License
MIT