@ 经典/ mongokit
Mongoose apps的生产准备寄存层: 租户安全 CRUD, 软删除, pagination, hooks, retries, dealers, change streams, 和插件.
蒙戈克特就是建立在这个基础上的 @classytic/repo-core 执行共享 StandardRepo<TDoc> 合同,因此控制器代码可以瞄准一个存储器接口跨越MongoDB和SQLite.
安装
npm install @classytic/mongokit @classytic/repo-core mongoose
所需经费:
- 节点.js
>=22 - 乌鸦
>=9.4.1 @classytic/repo-core >=0.19.0mongodb-memory-server >=10.4.3—— 说 选项,仅需要@classytic/mongokit/testkit子路径( 被宣布为可选对等点, 所以它是 从来没有为不使用它的消费者安装)
快速启动
使用 createRepository(model, config) 为了共同的道路。 它为您按安全顺序绘制插件 .
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 });
对于手动插件控制,请直接使用构造器:
import {
Repository,
methodRegistryPlugin,
multiTenantPlugin,
softDeletePlugin,
timestampPlugin,
} from '@classytic/mongokit';
const userRepo = new Repository(UserModel, [
methodRegistryPlugin(),
multiTenantPlugin({ tenantField: 'organizationId' }),
softDeletePlugin(),
timestampPlugin(),
]);
为什么是Mongokit?
- 房客安全: 房客过滤器在司机打电话前由政策钩注入.
- 软删除 : 默认软删除方式
restore, (中文(简体) ),getDeletedTTL的支持 和硬去除的逃生舱. - 铺设 : 为不安全的密钥集查询提供索引警告.
- 钩和中间器皿: 决定性
before:*, (中文(简体) ),after:*, (中文(简体) ),error:*钩子外加包装式中件. - 检索和取消:
retryPolicy财务报告和审定财务报表AbortSignal整个存储器表面的支持. - 交易 : 用于单repo和跨repo MongoDB交易的辅助API.
- 更改流 : 便携式
watch()API过蒙戈变流. - 跨吻合同: 可转让到
StandardRepo<TDoc>从@classytic/repo-core。 。 。 .
核心 API
读取:
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);
写作:
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);
语义学小姐跟随 MinimalRepo 合同:
getById, (中文(简体) ),update,以及delete返回时null关于小姐.- 无效的对象Id形状输入返回
null而不是冲浪出一只绵羊CastError。 。 。 . - 过
{ throwOnNotFound: true }选择404式错误.
常见食谱
多十年读写
const repo = createRepository(OrderModel, {
tenant: { tenantField: 'organizationId', required: true },
});
await repo.create({ total: 120 }, { organizationId });
await repo.findAll({ status: 'paid' }, { organizationId });
若为 required: true 无法解决租户的问题,在任何司机打电话前,电话无法拨打.
软删除和硬删除
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
硬删除仍然运行着租户,审计,缓存和验证钩.
倾斜
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,
});
每一类都有一个独特的 _id 断线器和 hasNext 来源于限制+1获取。 偏移页面
报告 { total, totalIsEstimate, countedAt } 每栏下 countStrategy (单位:千美元)exact, (中文(简体) ), capped, (中文(简体) ),
estimated, (中文(简体) ), none, (中文(简体) ), cached页:1 skip 过去 maxOffset (违约10万)以下列方式被拒绝:
mongokit.pagination.offset_too_deep。光标绑定在它的收藏、过滤器上(租户)
和整理:在其他地方重放 repo.cursor.scope_mismatch (对等核心) CURSOR_ERROR_CODES页:1
cursorSecret 所以篡改也被发现了.
// Boot: the deployment's list policy.
configurePaginationDefaults({ defaultCountStrategy: 'capped', cursorSecret, maxOffset: 50_000 });
交易
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 });
});
单还原便利:
await orderRepo.withTransaction(async (txRepo) => {
return txRepo.create(orderData, { organizationId });
});
要连载作家,写作必须改变. 将字段设置到它已经拥有的值的更新是无可操作的:它不需要写入锁,所以两个交易在同一瞬间完成,从不冲突. 写有变化的东西( 例如) Name $inc: { writes: 1 } 在大门或租赁行.
时限和关切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 });
加入范围
A 类 $lookup 编号 : $unionWith 编号 : $graphLookup 在自己的仓库下读取已合并的收藏
租户和软删除范围。 拿着 from 从 Model.collection.name: 没有模型拥有的名称丢出
mongokit.join.unknown_collection,没有寄存器支配丢弃
mongokit.join.ungoverned_collection (列出一个全公司在 unscopedJoins) (中文(简体) ).
批量读取( E)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
读取模型( E)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 } });
活性扫荡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 }
}
改变流
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() 与其它读取的一样是插件路由的,所以在蒙戈变流管打开之前,会应用租户和软去除过滤器.
查询解析器
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);
支持常见的 URL 运算符, 如 _gt, (中文(简体) ), _gte, (中文(简体) ), _lt, (中文(简体) ), _lte, (中文(简体) ), _in, (中文(简体) ), _nin, (中文(简体) ), _regex地理过滤器 聚居物 以及有知觉的强迫.
解析器是 默认关闭失败 (单位:千美元)invalidInput: 'throw': 被封锁
运算符、不允许的字段、错误的值和病理正则输入
提高HTTP 400 (code: 'INVALID_QUERY_INPUT'而不是沉默无声
驳回请求 仅限 过滤器会将查询扩展至每个
租户范围记录。 文-语义输入( E)search, (中文(简体) ), like编号 :contains(中文(简体) )
总是逃避而不是拒绝,所以 ?search=c++ 从来没有400s。 已经信任
迁移/匹配工具可以选择使用遗留的警告和滴放行为
invalidInput: 'drop'。 。 。 .
前缀搜索( 搜索- as- you- type, 有索引)
searchMode: 'prefix' 找到每个查询词开始一个字的文档
searchFields (单位:千美元)moh ali 找到"Mohammed Al-Ali",由多键索引提供
而不是一个无忧无虑的Regex扫描。 寄存器会写一个普通的单词
键入 searchKeysField (默认) _searchKeys在每一个写道上.
规范化:小写,有二甲酸盐脱落的NFKD,阿拉伯折叠
(删除了tashkeel/tatweel, أ إ آ → ا, (中文(简体) ), ى → ي, (中文(简体) ), ة → ه),拆分开
白相间/平相.
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
客户用同样的规则从无鼠标的子路径突出:
import { findSearchMatches } from '@classytic/mongokit/search'写着:
绕过寄存器( raw) Model.updateOne,更好的奥思的适配器)写出
键为 buildSearchKeys(doc, fields)。 。 。 .
内建插件
| 插件 | 目的 |
|---|---|
methodRegistryPlugin | 安全添加插件分配方法 |
multiTenantPlugin | 租户范围界定和租户盖章 |
softDeletePlugin | 软删除、 恢复、 删除列表查询、 TTL |
timestampPlugin | createdAt 编号 : updatedAt 管理层 |
searchKeysPlugin | 每个书写上的前缀搜索密钥( 自动安装于 searchMode: 'prefix'(中文(简体) ) |
batchOperationsPlugin | bulkWrite 和批量帮助者 |
cachePlugin | 可插取读取缓存 |
auditLogPlugin 编号 : auditTrailPlugin | 审计线索—— auditTrailPlugin({ mode: 'transactional' }) 给出内部为原子的会话连接条目 withTransaction (遵守级别);默认 'best-effort' 模式是防火和遗忘的可观察性。 打电话 ensureAuditTrailReady() 因此,在第一次交易中不会发生收藏/索引创建 |
observabilityPlugin | 量子钩点 |
cascadePlugin | Hook 路由级联删除 |
customIdPlugin | 前缀或相继的公共身份证 |
fieldFilterPlugin | 了解作用的外地可见度 |
validationChainPlugin | 分层同步和同步验证 |
mongoOperationsPlugin | Hook- routed Mongo 更新助手 |
aggregateHelpersPlugin | 常见的聚合助手 |
subdocumentPlugin | Nested 阵列文档助手 |
changeLogPlugin | 持久每个文件的改变种子(@classytic/repo-core/sync用于离线第一拉出/推出和复制品 |
只导入您使用的插件 .
事件和中间软件
每个操作都发出钩子:
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 });
});
Middleward 包裹成功操作执行 :
repo.useMiddleware(async ({ operation, next }) => {
const start = performance.now();
try {
return await next();
} finally {
metrics.record(operation, performance.now() - start);
}
});
使用 before:* 安全政策的钩子。 使用中间软件来进行人机组学,如定时,追踪,以及输入/输出成型等.
类型脚本
import type {
Repository,
RepositoryContext,
SoftDeleteMethods,
BatchOperationsMethods,
} from '@classytic/mongokit';
type UserRepo = Repository<UserDoc> &
SoftDeleteMethods<UserDoc> &
BatchOperationsMethods;
输入您的 Mongoose 模型—— 绝不铸造 as never
以医生类型宣布型号 Model<UserDoc> 直接流入
Repository<UserDoc> 财务报告和审定财务报表 createMongooseAdapter 没有铸造物:
// ✅ 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
出品人并不是蒙哥基打字漏洞——是未打字模型的症状.
一个通用的 model<Doc>(...) 调用网站将其删除
(单位:千美元)Repository, (中文(简体) ), createMongooseAdapter,以及任意 RepositoryLike<Doc> 网站).
特征- 检测可移植行为 :
if (repo.capabilities.changeStreams) {
// repo.watch is available
}
子路径导入
import { parseGeoFilter } from '@classytic/mongokit/query/primitives/geo';
import { coerceFieldValue } from '@classytic/mongokit/query/primitives/coercion';
import { extractSchemaIndexes } from '@classytic/mongokit/query/primitives/indexes';
可选桥梁 :
import { createBetterAuthOverlay } from '@classytic/mongokit/better-auth';
高级文档
GitHub 寄存器包括以下更深入的指南:
- 增强安全性
- 查找和汇总模式
- 类型架构
- 贡献工作流程
- 释放检查
见 docs/ 这些长式引用的目录.
测试
npm test
npm run typecheck
npm run build
测试套房使用 mongodb-memory-server 默认。 设定 MONGODB_URI 以对抗外部蒙戈DB部署.
测试机率—— @classytic/mongokit/testkit
装有电池的吊带 用来测试你的储物库 旋转平面 MongoDB( 独立的, 或用于交易的单节点复制件), 打开一个 单独 连接,并给你一个活的mongokit Repository 在一个电话.
mongodb-memory-server 是一个 选项对等点 ——在使用试样时安装;它从来不是生产依赖性,是动态导入的,因此它从不输入您的应用包:
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)
运行于本地或云 DB
传出 a uri (或设置) MONGODB_URI和 没有启动模拟服务器 测试 kit 连接到你真正的数据库和 close()编号 :stop() 所以它不会放弃它:
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()虚空 每个收藏 在连接。 在真正的数据库中,总是指向 专用测试 DB 从不生产.
测试索引
createTestRepository 运行 model.init(),因此所宣布的指数是真正构建的——独特的约束性执行(E11000),TTL,复合物,文本,和地理学索引都真实表现在记忆服务器上. 使用已曝光内容 model 用于运行时间索引工作:
await t.model.syncIndexes();
await t.model.collection.indexes(); // inspect
await t.model.collection.dropIndex('tag_1'); // drop
Atlas $search 编号 : $vectorSearch 索引只使用阿特拉斯系统,在任何非阿特拉斯服务器上都不存在 uri 在真正的Atlas测试集群.
帮助者
| 帮助 | 使用 |
|---|---|
createTestRepository | 服务器 + 连接 + 直播 Repository 在一个电话中 |
createTestConnection | 孤立连接 + clear() 编号 : close() |
withMongoMemory(fn) | 设 设 — 运行 — 拆 (finally(中文(简体) ) |
createMongoMemory | 原始服务器寿命周期( n)uri + 键 stop(中文(简体) ) |
mongoMemoryBackend() | (单位:千美元) TestBackend 连接 @classytic/arc-testkit |
都接受,我们接受 { replset?, dbName?, uri? }。 。 。 .
查询计划门
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
});
默认读取有门; 通过 commands: ['findAndModify', 'update', 'delete'] 写得也一样.
explainCommands(run, { connection, commands?, collections?, verbosity? }) 返回每个字的原始解释
命令 run 为插件构建的查询,例如: leasePlugin 索赔:
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
内核配合—— @classytic/mongokit/kernel-conformance
一个 可执行合同套件 用于构建于 defineModels()内核
按描述 ^ 捆绑 → 校验 → 服务 → 关闭寿命周期电线进行自身测试
目录; 传送它符合.
它声称普通内核测试没有做到的: defineX(shape) 没有登记,那
bind 准确登记SUPPLIED连接上申报的模型,不执行索引
I/O,第二个捆绑扔 ModelCollisionError,即提供主机的事件运输
从来没有关闭 当一个内部创建的 close() 有天分,没有遗产
createX编号 :destroy编号 :dispose 表面幸存,一个失败的索引同步REJECS
而不是被吞没.
试跑者注射,从未导入 ——mongokit不需依赖(甚至不需依赖)
在vitest上可选的对等点),套房运行在任何暴露的跑道下无修改
describe编号 :it请求是内部的,所以没有 expect 需要执行.
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: '…' }],
});
硬化准备: tenantUniques
MongoDB 拒绝 shardCollection 任何独一无二的指数都缺少这个键作为它的
前缀,所以一个全球独有的索引使得一个收藏被租户无法硬化. 供应
tenantUniques 启用 unique-indexes-tenant-prefixed: 套房将内核绑起
租借权,阅读每个注册的计划指数,并要求每个独特的指数与
租户田地,或 allowGlobal 说明理由和处理时间
部署被硬化或被分解为舱(shard-key-prefix, (中文(简体) ), identity-registry, (中文(简体) ), pod-local 或者说
drop) (中文(简体) ). 当没有检查过独特的指数时,它也失败了
allowGlobal 输入.
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' }],
},
检查 ID 导出为 KERNEL_CONFORMANCE_CHECKS 联合国 const tu,所以 skip 是输入的
union —— 错误的 id 是编译错误,而不是无效的跳过.
许可证
麻省理工学院
商标
密码是麻省理工学院许可的. "classytic","arc",并标志为: 经典专卖局长 并且现在 没有 根据麻省理工学院颁发的许可证——见 贸易商。 。 。 . 叉子必须更名;许可证覆盖了代码,而不是品牌.