@classytic/mongokit
v3.24.0
Published
Production-grade MongoDB repositories - smart pagination, events, and plugins. Built on @classytic/repo-core.
Keywords
Readme
@classytic/mongokit
Production-ready repository layer for Mongoose apps: tenant-safe CRUD, soft delete, pagination, hooks, retries, transactions, change streams, and plugins.
Mongokit is built on @classytic/repo-core and implements the shared StandardRepo<TDoc> contract, so controller code can target one repository interface across MongoDB and SQLite.
Install
npm install @classytic/mongokit @classytic/repo-core mongooseRequirements:
- Node.js
>=22 - Mongoose
>=9.4.1 @classytic/repo-core >=0.7.0
Quick Start
Use createRepository(model, config) for the common path. It composes plugins in the safe order for you.
import { createRepository } from '@classytic/mongokit';
import UserModel from './models/User.js';
const userRepo = createRepository<UserDoc>(UserModel, {
tenant: { tenantField: 'organizationId' },
softDelete: true,
timestamps: true,
batch: true,
});
const user = await userRepo.create(
{ name: 'Ada', email: '[email protected]' },
{ organizationId },
);
const page = await userRepo.getAll({
page: 1,
limit: 20,
filters: { status: 'active' },
organizationId,
});
await userRepo.update(user._id, { name: 'Ada Lovelace' }, { organizationId });
await userRepo.delete(user._id, { organizationId });For manual plugin control, use the constructor directly:
import {
Repository,
methodRegistryPlugin,
multiTenantPlugin,
softDeletePlugin,
timestampPlugin,
} from '@classytic/mongokit';
const userRepo = new Repository(UserModel, [
methodRegistryPlugin(),
multiTenantPlugin({ tenantField: 'organizationId' }),
softDeletePlugin(),
timestampPlugin(),
]);Why Mongokit?
- Tenant safety: tenant filters are injected by policy hooks before driver calls.
- Soft delete: default soft delete with
restore,getDeleted, TTL support, and hard-delete escape hatches. - Pagination: offset and keyset pagination with index warnings for unsafe keyset queries.
- Hooks and middleware: deterministic
before:*,after:*,error:*hooks plus wrap-style middleware. - Retries and cancellation:
retryPolicyandAbortSignalsupport across the repository surface. - Transactions: helper APIs for single-repo and cross-repo MongoDB transactions.
- Change streams: portable
watch()API over Mongo change streams. - Cross-kit contract: assignable to
StandardRepo<TDoc>from@classytic/repo-core.
Core API
Reads:
await repo.getById(id, options);
await repo.getByQuery({ email }, options);
await repo.getOne({ slug }, options);
await repo.findAll({ status: 'active' }, options);
await repo.getAll({ page: 1, limit: 20, filters });
await repo.count(filters, options);
await repo.exists(filters, options);
await repo.distinct('status', filters, options);Writes:
await repo.create(data, options);
await repo.createMany(rows, options);
await repo.update(id, patch, options);
await repo.updateMany(filter, patch, options);
await repo.delete(id, options);
await repo.deleteMany(filter, options);
await repo.findOneAndUpdate(filter, update, options);Miss semantics follow the MinimalRepo contract:
getById,update, anddeletereturnnullon miss.- Invalid ObjectId-shaped input returns
nullinstead of surfacing a MongooseCastError. - Pass
{ throwOnNotFound: true }to opt into 404-style errors.
Common Recipes
Multi-Tenant Reads And Writes
const repo = createRepository(OrderModel, {
tenant: { tenantField: 'organizationId', required: true },
});
await repo.create({ total: 120 }, { organizationId });
await repo.findAll({ status: 'paid' }, { organizationId });If required: true is set and no tenant can be resolved, the call fails closed before any driver call.
Soft Delete And Hard Delete
const repo = createRepository(UserModel, {
softDelete: { deletedField: 'deletedAt' },
});
await repo.delete(userId); // soft delete
await repo.restore(userId); // plugin method
await repo.delete(userId, { mode: 'hard' }); // physical deleteHard delete still runs tenant, audit, cache, and validation hooks.
Pagination
const offsetPage = await repo.getAll({
mode: 'offset',
page: 1,
limit: 25,
sort: { createdAt: -1 },
});
const first = await repo.getAll({
mode: 'keyset',
limit: 25,
sort: { createdAt: -1 },
});
const next = await repo.getAll({
mode: 'keyset',
limit: 25,
sort: { createdAt: -1 },
after: first.next,
});Transactions
import { withTransaction } from '@classytic/mongokit';
await withTransaction(mongoose.connection, async (session) => {
const order = await orderRepo.create(orderData, { session, organizationId });
await ledgerRepo.create({ orderId: order._id, amount: order.total }, { session, organizationId });
});Single-repo convenience:
await orderRepo.withTransaction(async (txRepo) => {
return txRepo.create(orderData, { organizationId });
});Change Streams
const ac = new AbortController();
for await (const change of repo.watch({ status: 'pending' }, {
signal: ac.signal,
organizationId,
} as { signal: AbortSignal; organizationId: string })) {
if (change.operation === 'create') enqueue(change.doc);
}watch() is plugin-routed like other reads, so tenant and soft-delete filters are applied before the Mongo change-stream pipeline opens.
Query Parser
import { QueryParser } from '@classytic/mongokit';
const parser = new QueryParser({
schema: InvoiceModel.schema,
allowedFilterFields: ['status', 'total', 'customerId'],
allowedSortFields: ['createdAt', 'total'],
searchMode: 'auto',
});
const parsed = parser.parse(req.query);
const result = await invoiceRepo.getAll(parsed);Supports common URL operators such as _gt, _gte, _lt, _lte, _in, _nin, _regex, geo filters, populate, and schema-aware coercion.
Built-In Plugins
| Plugin | Purpose |
| --- | --- |
| methodRegistryPlugin | Adds plugin-contributed methods safely |
| multiTenantPlugin | Tenant scoping and tenant stamping |
| softDeletePlugin | Soft delete, restore, deleted-list queries, TTL |
| timestampPlugin | createdAt / updatedAt management |
| batchOperationsPlugin | bulkWrite and batch helpers |
| cachePlugin | Pluggable read-through cache |
| auditLogPlugin / auditTrailPlugin | Operational and compliance audit trails |
| observabilityPlugin | Metrics hook points |
| cascadePlugin | Hook-routed cascade delete |
| customIdPlugin | Prefixed or sequential public IDs |
| fieldFilterPlugin | Role-aware field visibility |
| validationChainPlugin | Layered sync and async validation |
| mongoOperationsPlugin | Hook-routed Mongo update helpers |
| aggregateHelpersPlugin | Common aggregation helpers |
| subdocumentPlugin | Nested array document helpers |
| changeLogPlugin | Durable per-doc change feed (@classytic/repo-core/sync) for offline-first pull/push and replicas |
Import only the plugins you use.
Events And Middleware
Every operation emits hooks:
repo.on('before:delete', (context) => {
if (!context.user) throw new Error('unauthorized');
});
repo.on('after:create', ({ context, result }) => {
audit.info({ model: context.model, id: result._id });
});Middleware wraps successful operation execution:
repo.useMiddleware(async ({ operation, next }) => {
const start = performance.now();
try {
return await next();
} finally {
metrics.record(operation, performance.now() - start);
}
});Use before:* hooks for security policy. Use middleware for ergonomics such as timing, tracing, and input/output shaping.
TypeScript
import type {
Repository,
RepositoryContext,
SoftDeleteMethods,
BatchOperationsMethods,
} from '@classytic/mongokit';
type UserRepo = Repository<UserDoc> &
SoftDeleteMethods<UserDoc> &
BatchOperationsMethods;Type your Mongoose model — never cast as never
Declare the model with its doc type so Model<UserDoc> flows straight into
Repository<UserDoc> and createMongooseAdapter with no casts:
// ✅ Typed — flows cleanly into Repository + the arc adapter
export type UserDoc = InferSchemaType<typeof UserSchema> & { _id: Types.ObjectId };
export const UserModel = model<UserDoc>('User', UserSchema);
export const userRepo = new Repository<UserDoc>(UserModel, [/* plugins */]);
// ❌ Untyped — the inferred doc type diverges from `UserDoc`, and
// `mongoose.Model<T>` is INVARIANT in T, so it won't unify. The
// tempting "fix" is `as never` — don't; type the model instead.
export const UserModel = model('User', UserSchema); // Model<inferred>
new Repository<UserDoc>(UserModel as never, []); // ⛔ cast smellThe cast isn't a mongokit typing gap — it's a symptom of an untyped model.
One generic at the model<Doc>(...) call site removes it everywhere
(Repository, createMongooseAdapter, and any RepositoryLike<Doc> site).
Feature-detect portable behavior with:
if (repo.capabilities.changeStreams) {
// repo.watch is available
}Subpath Imports
import { parseGeoFilter } from '@classytic/mongokit/query/primitives/geo';
import { coerceFieldValue } from '@classytic/mongokit/query/primitives/coercion';
import { extractSchemaIndexes } from '@classytic/mongokit/query/primitives/indexes';Optional bridges:
import { createBetterAuthOverlay } from '@classytic/mongokit/better-auth';Advanced Documentation
The GitHub repository includes deeper guides for:
- security hardening
- lookup and aggregation patterns
- type architecture
- contribution workflow
- release checks
See the docs/ directory in the repository for those long-form references.
Testing
npm test
npm run typecheck
npm run buildThe test suite uses mongodb-memory-server by default. Set MONGODB_URI to run against an external MongoDB deployment.
Test Harness — @classytic/mongokit/testkit
A batteries-included harness for testing your own repositories. Spins an ephemeral MongoDB (standalone, or a single-node replica set for transactions), opens an isolated connection, and gives you a live mongokit Repository in one call.
mongodb-memory-server is an optional peer — install it where you use the testkit; it is never a production dependency and is dynamically imported, so it never enters your app bundle:
npm i -D mongodb-memory-serverimport { createTestRepository } from '@classytic/mongokit/testkit';
const t = await createTestRepository({
name: 'Order',
schema: orderSchema,
config: { softDelete: true, timestamps: true }, // full CreateRepositoryConfig
});
await t.repository.create({ total: 10 });
await t.clear(); // empty the collection between tests
await t.close(); // close connection + stop server (idempotent)Run against your own local or cloud DB
Pass a uri (or set MONGODB_URI) and no in-memory server starts — the testkit connects to your real database and close()/stop() become no-ops, so it never drops it:
const t = await createTestRepository({
name: 'Order', schema: orderSchema,
uri: process.env.ATLAS_TEST_URI ?? 'mongodb://localhost:27017/myapp-test',
});# or for the whole suite, no code change:
MONGODB_URI="mongodb+srv://…/myapp-test" vitest run⚠️
clear()empties every collection on the connection. Against a real database, always point at a dedicated test DB — never production.
Testing indexes
createTestRepository runs model.init(), so declared indexes are really built — unique-constraint enforcement (E11000), TTL, compound, text, and geo indexes all behave for real on the in-memory server. Use the exposed model for runtime index work:
await t.model.syncIndexes();
await t.model.collection.indexes(); // inspect
await t.model.collection.dropIndex('tag_1'); // dropAtlas $search / $vectorSearch indexes are Atlas-only and don't exist on any non-Atlas server — test those by pointing uri at a real Atlas test cluster.
Helpers
| Helper | Use |
| --- | --- |
| createTestRepository | server + connection + live Repository in one call |
| createTestConnection | isolated connection + clear() / close() |
| withMongoMemory(fn) | scoped setup → run → teardown (finally) |
| createMongoMemory | raw server lifecycle (uri + stop) |
| mongoMemoryBackend() | a TestBackend seam for @classytic/arc-testkit |
All accept { replset?, dbName?, uri? }.
License
MIT
