@classytic/mongokit
v3.40.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.19.0mongodb-memory-server >=10.4.3— optional, needed only by the@classytic/mongokit/testkitsubpath (declared as an optional peer, so it is never installed for consumers that don't use it)
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); // throws HttpError(400) on invalid/blocked input
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.
The parser is fail-closed by default (invalidInput: 'throw'): blocked
operators, disallowed fields, malformed values, and pathological regex input
raise HTTP 400 (code: 'INVALID_QUERY_INPUT') instead of being silently
dropped — dropping a request's only filter would broaden the query to every
record in tenant scope. Literal-semantics input (search, like/contains)
always escapes rather than rejects, so ?search=c++ never 400s. Trusted
migration/compat tooling can opt into the legacy warn-and-drop behavior with
invalidInput: 'drop'.
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 | Audit trails — auditTrailPlugin({ mode: 'transactional' }) gives session-joined entries that are atomic inside withTransaction (compliance-grade); the default 'best-effort' mode is fire-and-forget observability. Call ensureAuditTrailReady() at boot so collection/index creation doesn't happen inside the first transaction |
| 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? }.
Kernel Conformance — @classytic/mongokit/kernel-conformance
An executable contract suite for domain kernels built on defineModels(). A kernel that
follows the Describe → Bind → Verify → Serve → Close lifecycle wires this into its own test
directory; passing it IS conformance.
It asserts what ordinary kernel tests do not: that defineX(shape) registers nothing, that
bind registers exactly the declared models on the SUPPLIED connection and performs no index
I/O, that a second bind throws ModelCollisionError, that a host-supplied event transport is
never closed while an internally-created one is, that close() is idempotent, that no legacy
createX/destroy/dispose surface survives, and that a failing index sync REJECTS rather
than being swallowed.
The test runner is injected, never imported — mongokit takes no dependency (not even an
optional peer) on vitest, and the suite runs unmodified under any runner that exposes
describe/it. Assertions are internal, so no expect implementation is required.
import mongoose from 'mongoose';
import { describe, it } from 'vitest';
import { describeKernelConformance } from '@classytic/mongokit/kernel-conformance';
import * as kernel from '../../src/index.js';
describeKernelConformance({
name: 'party',
runner: { describe, it },
// A FACTORY — purity is only observable if the suite can call defineX itself.
blueprint: () => kernel.defineParty({ multiTenant: false, autoIndex: false }),
// Never connected: every check is registry-level, so no server is needed.
connect: async () => mongoose.createConnection(),
bind: (bp, conn, ctx) => bp.bind(conn, { eventTransport: ctx.transport }),
expectedModelNames: ['Party'],
moduleExports: kernel,
// Opt-outs are explicit, typed, and REQUIRE a reason; each one still emits a
// visible `[SKIPPED] <id> — <reason>` test. A silent skip is the bug this suite exists
// to prevent.
skip: [{ check: 'bind-verifies-requirements', reason: '…' }],
});Check ids are exported as KERNEL_CONFORMANCE_CHECKS (a const tuple, so skip is a typed
union — a mistyped id is a compile error, not an ineffective skip).
License
MIT
Trademark
The code is MIT-licensed. "Classytic", "arc", and the logos are trademarks of Classytic LLC and are not licensed under MIT — see TRADEMARK.md. Forks must be renamed; the license covers the code, not the brand.
