@classytic/mongokit

中文文档
3.46.0发布于 2026/11/08MITTypeScriptES Module零依赖近 30 天下载 2843

Production-grade MongoDB repositories - smart pagination, events, and plugins. Built on @classytic/repo-core.

原始使用文档
文档目录33 节
  1. Install
  2. Quick Start
  3. Why Mongokit?
  4. Core API
  5. Common Recipes
  6. Multi-Tenant Reads And Writes
  7. Soft Delete And Hard Delete
  8. Pagination
  9. Transactions
  10. Time Bounds And Concerns ( queryDefaults )
  11. Joins Are Scoped
  12. Batch Reads ( getByIds )
  13. Read Models ( bulkUpsert , @classytic/mongokit/read-model )
  14. Resumable Sweeps ( iterate )
  15. Change Streams
  16. Query Parser
  17. Prefix search (search-as-you-type, indexed)
  18. Built-In Plugins
  19. Events And Middleware
  20. TypeScript
  21. Type your Mongoose model — never cast as never
  22. Subpath Imports
  23. Advanced Documentation
  24. Testing
  25. Test Harness — @classytic/mongokit/testkit
  26. Run against your own local or cloud DB
  27. Testing indexes
  28. Helpers
  29. Query-plan gate
  30. Kernel Conformance — @classytic/mongokit/kernel-conformance
  31. Sharding readiness: tenantUniques
  32. License
  33. Trademark

@classytic/mongokit

npm version License: MIT Sponsor

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.0
  • mongodb-memory-server >=10.4.3 — optional, needed only by the @classytic/mongokit/testkit subpath (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: retryPolicy and AbortSignal support 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, and delete return null on miss.
  • Invalid ObjectId-shaped input returns null instead of surfacing a Mongoose CastError.
  • 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

PluginPurpose
methodRegistryPluginAdds plugin-contributed methods safely
multiTenantPluginTenant scoping and tenant stamping
softDeletePluginSoft delete, restore, deleted-list queries, TTL
timestampPlugincreatedAt / updatedAt management
searchKeysPluginPrefix-search keys on every write (auto-installed by searchMode: 'prefix')
batchOperationsPluginbulkWrite and batch helpers
cachePluginPluggable read-through cache
auditLogPlugin / auditTrailPluginAudit 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
observabilityPluginMetrics hook points
cascadePluginHook-routed cascade delete
customIdPluginPrefixed or sequential public IDs
fieldFilterPluginRole-aware field visibility
validationChainPluginLayered sync and async validation
mongoOperationsPluginHook-routed Mongo update helpers
aggregateHelpersPluginCommon aggregation helpers
subdocumentPluginNested array document helpers
changeLogPluginDurable 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

HelperUse
createTestRepositoryserver + connection + live Repository in one call
createTestConnectionisolated connection + clear() / close()
withMongoMemory(fn)scoped setup → run → teardown (finally)
createMongoMemoryraw 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.

版本与发布记录

@classytic/mongokit 共发布 97 个版本,首次发布于 2025/11/09;过去 12 个月发布了 95 个版本。

版本发布日期距今
3.46.0latest2026/11/081 天前
3.45.02026/11/063 天前
3.44.02026/11/036 天前
3.43.12026/10/2119 天前
3.43.02026/10/1723 天前
3.42.02026/10/1426 天前

关于 @classytic/mongokit 的常见问题

@classytic/mongokit 是什么?

Production-grade MongoDB repositories - smart pagination, events, and plugins. Built on @classytic/repo-core.

如何安装 @classytic/mongokit?

在项目目录运行 npm install @classytic/mongokit,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:mongoose(>=9.12.0)、@classytic/repo-core(>=0.38.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@classytic/mongokit 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@classytic/mongokit 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@classytic/mongokit 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@classytic/mongokit 可以商用吗?

@classytic/mongokit 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@classytic/mongokit 的下载量有多少?

根据 npm 官方下载统计,@classytic/mongokit 在 2026/10/10 – 2026/11/08 这 30 天内被下载 2,843 次,日均约 95 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@classytic/mongokit 还在维护吗?

最新版本 3.46.0 发布于 2026/11/08(1 天前);至今共发布 97 个版本,其中过去 12 个月发布了 95 个;首次发布于 2025/11/09。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。