@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 mongoose
Requirements:
- 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 delete
Hard 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,
});
Every sort gets a unique _id tiebreaker and hasNext comes from a limit+1 fetch. Offset pages
report { total, totalIsEstimate, countedAt } under every countStrategy (exact, capped,
estimated, none, cached); a skip past maxOffset (default 100,000) is refused with
mongokit.pagination.offset_too_deep. A cursor is bound to its collection, filter (tenant
included) and collation: replayed elsewhere it is repo.cursor.scope_mismatch (repo-core CURSOR_ERROR_CODES); set
cursorSecret so tampering is detected too.
// Boot: the deployment's list policy.
configurePaginationDefaults({ defaultCountStrategy: 'capped', cursorSecret, maxOffset: 50_000 });
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 });
});
To serialise writers on a row, the write must change it. An update that sets a field to the value it already has is a no-op: it takes no write lock, so two transactions doing it at the same instant never conflict. Write something that changes (for example $inc: { writes: 1 } on a gate or lease row).
Time Bounds And Concerns (queryDefaults)
// Boot: every command is bounded by a server-side maxTimeMS; the boot check fails without it.
// Do not pass `timeoutMS` to mongoose.connect(): the MongoDB driver ignores it since 7.8 (CVE-2026-119179).
await mongoose.connect(uri);
configureQueryDefaults({ maxTimeMS: 5_000, writeConcern: { w: 'majority' } });
configureAggregateDefaults({ maxTimeMs: 15_000 });
assertQueryDefaultsConfigured();
// Per repository, and per call (per call always wins):
const repo = new Repository(Order, plugins, {}, { queryDefaults: { comment: 'orders' } });
await repo.findAll(filter, { maxTimeMS: 500 });
Joins Are Scoped
A $lookup / $unionWith / $graphLookup reads the joined collection under its own repository's
tenant and soft-delete scope. Take from from Model.collection.name: a name no model owns throws
mongokit.join.unknown_collection, a collection no repository governs throws
mongokit.join.ungoverned_collection (list a company-wide one in unscopedJoins).
Batch Reads (getByIds)
const byId = await repo.getByIds(ids, { select: 'name sku' }); // Map, chunked $in
const rows = await repo.getByIds(ids, { preserveOrder: true, chunkSize: 5_000 }); // aligned, undefined = miss
Read Models (bulkUpsert, @classytic/mongokit/read-model)
const res = await totals.bulkUpsert(rows, { key: ['period', 'rate'], inc: ['tax'], organizationId });
// res.results[i].outcome: 'inserted' | 'updated' | 'unchanged' | 'failed' (+ code). Needs MongoDB 8.0.
import { applyIncrements, rebuildInto, reconcile } from '@classytic/mongokit/read-model';
await applyIncrements(totals, grains, { session, ledger, organizationId }); // inside the posting transaction
await rebuildInto(facts, totals, { pipeline, on: ['organizationId', 'period', 'rate'], scope: { organizationId } });
const { drift } = await reconcile(facts, totals, { pipeline, on, measures: ['tax'], scope: { organizationId } });
Resumable Sweeps (iterate)
for await (const { docs, checkpoint } of repo.iterate(filter, { batchSize: 1_000, after: saved, organizationId })) {
await process(docs);
await saveCheckpoint(checkpoint); // resume later with { after: checkpoint }
}
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'.
Prefix search (search-as-you-type, indexed)
searchMode: 'prefix' finds documents where every query word starts a word of
searchFields (moh ali finds "Mohammed Al-Ali"), served by a multikey index
instead of an unanchored regex scan. The repository writes the normalised word
tokens into searchKeysField (default _searchKeys) on every write path.
Normalisation: lower-case, NFKD with diacritics stripped, Arabic folding
(tashkeel/tatweel removed, أ إ آ → ا, ى → ي, ة → ه), split on
whitespace/punctuation.
import { rebuildSearchKeys, searchKeysIndex, searchKeysPath } from '@classytic/mongokit';
schema.add({ _searchKeys: searchKeysPath() }); // string[], select: false
// Serves getAll({ search }) prefix search.
schema.index(searchKeysIndex().fields, searchKeysIndex().options);
const repo = new Repository(Person, [], {}, { searchMode: 'prefix', searchFields: ['name', 'email'] });
await rebuildSearchKeys(repo, { onlyMissing: true }); // backfill rows written before
await repo.getAll({ search: 'moh', page: 1 }); // composes with filters, tenant scope, keyset/offset, counts
Clients highlight with the same rules from the mongoose-free subpath:
import { findSearchMatches } from '@classytic/mongokit/search'. Writes that
bypass the repository (raw Model.updateOne, Better Auth's adapter) write the
keys with buildSearchKeys(doc, fields).
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 |
searchKeysPlugin | Prefix-search keys on every write (auto-installed by searchMode: 'prefix') |
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 smell
The 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 build
The 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-server
import { 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'); // drop
Atlas $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? }.
Query-plan gate
import { assertQueryPlan } from '@classytic/mongokit/testkit';
// The connection must be opened with { monitorCommands: true }.
await assertQueryPlan(() => repo.getAll({ filters: { status: 'open' }, sort: { at: -1 } }), {
connection, minDocs: 1_000, leadingKeys: ['status', 'at'], // fails on COLLSCAN / unindexed SORT
});
Reads are gated by default; pass commands: ['findAndModify', 'update', 'delete'] to gate a write's match too.
explainCommands(run, { connection, commands?, collections?, verbosity? }) returns the raw explain of every
command run sent (writes are explained, never re-run), for a plugin-built query such as a leasePlugin claim:
import { explainCommands } from '@classytic/mongokit/testkit';
const { explains } = await explainCommands(() => jobs.lease({ leaseFor: 30_000, leasedBy }), {
connection, commands: ['findAndModify'], collections: ['jobs'],
});
explains[0].explain.executionStats; // what the claim examined
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: '…' }],
});
Sharding readiness: tenantUniques
MongoDB refuses shardCollection on a tenant key while ANY unique index lacks that key as its
prefix, so one global unique index makes a collection unshardable by tenant. Supply
tenantUniques to enable unique-indexes-tenant-prefixed: the suite binds the kernel with
tenancy ON, reads every registered schema's indexes, and requires each unique one to lead with the
tenant field, or to appear in allowGlobal with a reason and a disposition for when the
deployment is sharded or split into pods (shard-key-prefix, identity-registry, pod-local or
drop). It also fails when no unique index was inspected, and on a stale or reasonless
allowGlobal entry.
tenantUniques: {
tenantField: 'organizationId',
bindScoped: (conn) => kernel.defineParty({ tenant: { enabled: true }, autoIndex: false }).bind(conn, runtime),
allowGlobal: [{ index: 'party_site_slug', reason: 'public slug, no tenant', t2: 'identity-registry' }],
},
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.