LibreChat @librechat/data-schemas:用 Mongoose Schema、Model 工厂与数据库方法层组织多租户 AI 应用的数据
本篇以 packages/data-schemas/README.md 为主体,系统讲解 LibreChat 数据层包 @librechat/data-schemas 的分层架构:Schema 定义、TypeScript 类型、Model 工厂函数与数据库方法层四类约定,并结合仓库中 user schema 实现、createModels 汇总 与 服务端接入代码 等真实源码,给出完整的新实体接入七步流程与索引、虚拟属性等最佳实践,读完后你能够独立在该包中新增实体、编写数据库方法并通过类型检查。
一、包定位:LibreChat 的数据库访问底座
@librechat/data-schemas 是 LibreChat 的数据库 Schema、模型、类型与操作方法的集合包,基于 Mongoose ODM 构建。从 package.json 可以看到它的关键工程属性:
- 包名为
@librechat/data-schemas,当前版本0.0.68,type: module,MIT 协议; - 构建产物为双格式:
dist/index.cjs(CommonJS)与dist/index.mjs(ESM),通过exports字段分别暴露import与require条件,因此既服务 ESM 的 API 端,也可被传统 CJS 代码引入; - 通过
peerDependencies声明运行时依赖边界:mongoose ^8.24.1、librechat-data-provider(枚举常量来源)、winston(日志)、meilisearch(搜索同步)、jsonwebtoken、klona等均由宿主应用提供; - 开发侧使用
jest ^30+mongodb-memory-server做内存 MongoDB 集成测试,构建工具为tsdown。
README 给出的包结构(README 原文)如下:
packages/data-schemas/
├── src/
│ ├── schema/ # Mongoose schema definitions
│ ├── models/ # Model factory functions
│ ├── types/ # TypeScript type definitions
│ ├── methods/ # Database operation methods
│ ├── common/ # Shared constants and enums
│ ├── config/ # Configuration files (winston, etc.)
│ └── index.ts # Main package exports
对照当前仓库的实际目录,src/ 下还包含若干文档未列出的扩展子目录:app/(面向应用层接口的辅助逻辑)、admin/(管理端能力,如 capabilities)、crypto/、migrations/(索引迁移、回填任务)与 utils/(租户上下文、事务、对象操作等工具)。主入口 src/index.ts 汇总导出 createModels()、createMethods()、全部类型、租户上下文(tenantStorage、getTenantId 等)以及索引迁移函数(createMCPAuthorityLookupIndexes、dropSupersededTenantIndexes 等),是整个包的对外契约面。
二、架构约定:Schema、Types、Model 工厂与 Methods 四层
README 的核心是四个架构模式约定。下面逐层说明,并用仓库源码印证这些约定是如何落地的。
2.1 Schema 层(src/schema/)
约定要点:
- 命名:文件名使用小写(如
user.ts、accessRole.ts); - 导入:从
~/types导入类型以获得 TypeScript 支持; - 导出:仅以 default 导出 schema。
README 给出的示例:
import { Schema } from 'mongoose';
import type { IUser } from '~/types';
const userSchema = new Schema<IUser>(
{
name: { type: String },
email: { type: String, required: true },
// ... other fields
},
{ timestamps: true }
);
export default userSchema;
仓库中真实的 schema/user.ts 完全遵循该约定,并展示了生产级 schema 的更多细节:
const userSchema: Schema<IUser> = new Schema<IUser>(
{
username: { type: String, lowercase: true, default: '' },
email: {
type: String,
required: [true, "can't be blank"],
lowercase: true,
match: [/\S+@\S+\.\S+/, 'is invalid'],
index: true,
},
password: {
type: String,
trim: true,
minlength: 8,
maxlength: 128,
select: false, // 敏感字段默认不进入查询结果
},
totpSecret: { type: String, select: false }, // 2FA 密钥同样 select: false
expiresAt: { type: Date, expires: 604800 }, // MongoDB TTL:7 天
tenantId: { type: String, index: true },
},
{ timestamps: true },
);
可以看到几个 README「最佳实践」中提到的手法在此均有落地:select: false 用于把 password、totpSecret、backupCodes 等敏感字段排除出默认查询投影;expires 定义 TTL 索引让 MongoDB 自动清理过期会话字段;timestamps: true 统一维护 createdAt/updatedAt。
2.2 类型层(src/types/)
约定要点:
- 基础类型:定义不含 Mongoose Document 属性的 plain type;
- Document 接口:在基础类型上叠加
Document与_id; - 枚举/常量:相关枚举放在类型文件中,若是跨实体共享则放入
common/。
示例(README 原文):
import type { Document, Types } from 'mongoose';
export type User = {
name?: string;
email: string;
// ... other fields
};
export type IUser = User &
Document & {
_id: Types.ObjectId;
};
IUser 这类「接口类型」正是 schema 层 new Schema<IUser> 泛型与 methods 层 Model<IUser> 断言的共同类型来源,形成「types → schema → models → methods」的类型传递链。跨实体共享的常量(如 common/permissions.ts 中的权限位)则集中放在 src/common/ 下。
2.3 Model 工厂函数(src/models/)
约定要点:
- 函数命名:
create[EntityName]Model; - 单例模式:创建前检查
mongoose.models是否已注册同名模型; - 类型安全:使用 types 中对应的接口。
README 示例:
import userSchema from '~/schema/user';
import type * as t from '~/types';
export function createUserModel(mongoose: typeof import('mongoose')) {
return mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
}
仓库中真实的 models/user.ts 在此基础上多了一步——注册前调用租户隔离插件:
export function createUserModel(mongoose: typeof import('mongoose')): Model<t.IUser> {
applyTenantIsolation(userSchema);
return mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
}
applyTenantIsolation 定义在 models/plugins/tenantIsolation.ts,配套的 models/plugins/tenantIsolation.spec.ts 等测试文件印证了这是该包强制的多租户数据边界机制。工厂函数把 mongoose 作为参数显式注入而非模块内直接 import mongoose,正是为了在 Jest 环境中可以注入不同的 mongoose 实例做隔离测试。
所有工厂函数在 models/index.ts 中汇总为 createModels(mongoose),返回值逐一标注了类型(User: ReturnType<typeof createUserModel> 等)。当前版本共注册约 48 个模型,覆盖用户与会话(User、Token、Session、Balance)、对话核心(Conversation、Message、ChatProject、ToolCall)、Agent/MCP/Skill(Agent、AgentApiKey、MCPServer、Skill、SkillFile、AgentTriggerDelivery、AgentQueuedTurn)、权限审计(AccessRole、AclEntry、SystemGrant、AuditLog)等域。
该文件还有一段值得注意的实现:
/**
* Background index builds fail silently unless an 'index' listener is
* attached (e.g. Amazon DocumentDB <5.0 rejecting partialFilterExpression),
* leaving unique constraints unenforced with no trace in the logs.
*/
for (const model of Object.values(models)) {
if (model.listenerCount('index') === 0) {
model.on('index', (error?: Error) => {
if (error) {
logger.error(`Index build failed for "${model.modelName}": ${error.message}`);
}
});
}
}
从这段注释可以推断:在 Amazon DocumentDB 5.0 以下版本等环境中,后台索引构建失败(例如拒绝 partialFilterExpression)会静默吞掉错误,导致唯一约束形同虚设;因此 createModels 统一为未挂载监听器的模型补挂 index 事件监听,把建索引失败暴露到日志。
2.4 数据库方法层(src/methods/)
约定要点:
- 函数命名:
create[EntityName]Methods; - 返回类型:导出方法对象的类型(
ReturnType<typeof ...>); - 操作范围:包含 CRUD 与实体特有查询。
README 示例:
import type { Model } from 'mongoose';
import type { IUser } from '~/types';
export function createUserMethods(mongoose: typeof import('mongoose')) {
async function findUserById(userId: string): Promise<IUser | null> {
const User = mongoose.models.User as Model<IUser>;
return await User.findById(userId).lean();
}
async function createUser(userData: Partial<IUser>): Promise<IUser> {
const User = mongoose.models.User as Model<IUser>;
return await User.create(userData);
}
return {
findUserById,
createUser,
// ... other methods
};
}
export type UserMethods = ReturnType<typeof createUserMethods>;
注意方法内部统一用 .lean() 做读取(返回纯 JSON 而非 Mongoose Document),这是 README 最佳实践第 6 条「读操作不需要 Mongoose 文档方法时使用 .lean()」的直接体现。
methods/index.ts 把各实体的方法按复杂度分层组织(源码注释中明确标注了 Tier):
- Tier 1 — 简单 CRUD:
action、assistant、banner、toolCall、preset等; - Tier 2 — 中等复杂度,注入服务依赖:
conversationTag、message、conversation、chatProject等; - Tier 3 — 复杂逻辑,较重的依赖注入:
tx(令牌事务)、transaction、spendTokens、prompt、skill、schedule、queuedTurn、triggerDelivery等; - Tier 5 — Agent:
agent; - 另有 Config、MCP authority proofs、Insights 等独立分组。
所有方法通过展开运算符合并进 createMethods(mongoose) 返回的对象,并导出一个 AllMethods 交叉类型(UserMethods & SessionMethods & ...),使调用方获得完整的类型提示。主入口 src/index.ts 除方法对象外,还重导出了一批常量与领域错误(如 RoleConflictError、AgentQueuedTurnCapacityError、MCPAuthorityProofError 以及 MAX_AUDIT_LOG_LIMIT、DEFAULT_REFRESH_TOKEN_EXPIRY 等),使该包不仅是数据访问层,也是数据域常量的单一出处。
三、实战:真实索引设计 —— 以 User Schema 为例
README「Common Patterns」给出了复合索引与部分唯一索引的写法,而 schema/user.ts 提供了一个完整的真实案例:
userSchema.index({ email: 1, tenantId: 1 }, { unique: true });
userSchema.index({ role: 1, tenantId: 1 });
userSchema.index({ idOnTheSource: 1, openidIssuer: 1, tenantId: 1 });
const oAuthIdFields = [
'googleId', 'facebookId', 'openidId', 'samlId',
'ldapId', 'githubId', 'discordId', 'appleId',
] as const;
for (const field of oAuthIdFields) {
if (field === 'openidId') {
userSchema.index(
{ openidId: 1, openidIssuer: 1, tenantId: 1 },
{ unique: true, partialFilterExpression: { openidId: { $exists: true } } },
);
continue;
}
userSchema.index(
{ [field]: 1, tenantId: 1 },
{ unique: true, partialFilterExpression: { [field]: { $exists: true } } },
);
}
这段代码体现了三层设计意图:
- 租户维度复合唯一:
email + tenantId唯一,保证不同租户下邮箱可以重复、同租户内邮箱唯一,这是多租户 SaaS 的标准索引形态; - 部分索引(partial index):OAuth 外部 ID 字段对绝大多数用户是缺失的,若做全量唯一索引,所有缺失值会互相冲突;用
partialFilterExpression: { field: { $exists: true } }只对存在该字段的文档建立唯一约束,正是 README 中该模式的真实应用; - openidId 特殊处理:OIDC 的
openidId需要与openidIssuer联合唯一(同一 subject 在不同 issuer 下可能重复),因此其部分索引是三字段复合键。
配合 createModels 中统一的索引失败监听(第二节 2.3),这些约束在 DocumentDB 等受限环境中的失效也能被日志捕获。
四、新增实体七步流程(完整继承 README)
README 给出了在 data-schemas 包中新增实体的标准七步流程。以下是完整步骤,每步均保留 README 的原始代码模板。
Step 1:创建类型定义
新建 src/types/[entityName].ts:
import type { Document, Types } from 'mongoose';
export type EntityName = {
/** Field description */
fieldName: string;
// ... other fields
};
export type IEntityName = EntityName &
Document & {
_id: Types.ObjectId;
};
Step 2:更新 Types 索引
在 src/types/index.ts 中加入:
export * from './entityName';
Step 3:创建 Schema
新建 src/schema/[entityName].ts:
import { Schema } from 'mongoose';
import type { IEntityName } from '~/types';
const entityNameSchema = new Schema<IEntityName>(
{
fieldName: { type: String, required: true },
// ... other fields
},
{ timestamps: true }
);
export default entityNameSchema;
Step 4:创建 Model 工厂
新建 src/models/[entityName].ts:
import entityNameSchema from '~/schema/entityName';
import type * as t from '~/types';
export function createEntityNameModel(mongoose: typeof import('mongoose')) {
return (
mongoose.models.EntityName ||
mongoose.model<t.IEntityName>('EntityName', entityNameSchema)
);
}
Step 5:更新 Models 索引
在 src/models/index.ts 中:
- 导入工厂函数:
import { createEntityNameModel } from './entityName';
- 在
createModels()的返回对象中加入(同时按现有风格为返回值标注EntityName: ReturnType<typeof createEntityNameModel>类型):
EntityName: createEntityNameModel(mongoose),
注意:createModels 返回类型是显式声明的对象类型(见 models/index.ts),漏掉类型标注会导致类型收窄不完整。
Step 6:创建数据库方法
新建 src/methods/[entityName].ts:
import type { Model, Types } from 'mongoose';
import type { IEntityName } from '~/types';
export function createEntityNameMethods(mongoose: typeof import('mongoose')) {
async function findEntityById(id: string | Types.ObjectId): Promise<IEntityName | null> {
const EntityName = mongoose.models.EntityName as Model<IEntityName>;
return await EntityName.findById(id).lean();
}
// ... other methods
return {
findEntityById,
// ... other methods
};
}
export type EntityNameMethods = ReturnType<typeof createEntityNameMethods>;
Step 7:更新 Methods 索引
在 src/methods/index.ts 中:
- 导入方法与类型:
import { createEntityNameMethods, type EntityNameMethods } from './entityName';
- 在
createMethods()的返回对象中展开:
...createEntityNameMethods(mongoose),
- 将
EntityNameMethods加入AllMethods交叉类型:
export type AllMethods = UserMethods &
// ... other methods
EntityNameMethods;
(现有 createMethods 的合并写法与类型导出参见 methods/index.ts。)
五、最佳实践与常用模式
5.1 README 六条最佳实践
- 一致命名:文件名小写,类型/接口用 PascalCase;
- 类型安全:始终使用 TypeScript 类型,避免
any; - JSDoc 注释:为复杂字段与方法编写文档注释(如 schema/user.ts 中
idOnTheSource的注释说明其与 TPrincipal schema 的一致性); - 索引:在 schema 文件中为查询模式定义数据库索引以保证性能;
- 校验:使用 Mongoose schema 校验保证数据完整性(如 email 的
match正则、password 的长度约束); - Lean 查询:读操作不需要 Mongoose 文档方法时使用
.lean()。
5.2 枚举与常量
共享枚举放在 src/common/(README 示例):
// src/common/permissions.ts
export enum PermissionBits {
VIEW = 1,
EDIT = 2,
DELETE = 4,
SHARE = 8,
}
仓库中 src/common/ 实际包含 enum.ts、permissions.ts、pagination.ts 等共享定义;此外部分枚举常量也直接从 librechat-data-provider 包引入(如 schema/user.ts 中用于 role 默认值与 personalization.statefulCodeEnvironment 枚举的 SystemRoles、STATEFUL_CODE_ENVIRONMENTS),以与前端共享同一份常量。
5.3 复合索引与部分索引
schema.index({ field1: 1, field2: 1 });
schema.index(
{ uniqueField: 1 },
{
unique: true,
partialFilterExpression: { uniqueField: { $exists: true } }
}
);
真实案例见上文第三节的 User schema OAuth ID 部分唯一索引。
5.4 虚拟属性(Virtuals)
schema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
虚拟属性是文档级计算属性,不会写入 MongoDB;若需要在查询中过滤或索引该值,则应使用真实字段并配合默认值或更新逻辑。
六、服务端如何消费该包
理解包如何被 API 端调用,有助于理解其设计取舍。api/db/index.js 是整个数据层的服务端入口:
const mongoose = require('mongoose');
const { createModels } = require('@librechat/data-schemas');
const { connectDb } = require('./connect');
// createModels MUST run before requiring indexSync.
// indexSync.js captures mongoose.models.Message and mongoose.models.Conversation
// at module load time. If those models are not registered first, all MeiliSearch
// sync operations will silently fail on every startup.
createModels(mongoose);
const indexSync = require('./indexSync');
module.exports = { connectDb, indexSync };
这里有两条可复用的经验:
- 加载顺序即契约:MeiliSearch 同步模块
indexSync在模块加载时就捕获mongoose.models.Message与mongoose.models.Conversation的引用,因此createModels必须先于require('./indexSync')执行,否则搜索同步会在每次启动时静默失败; - CJS 宿主消费 ESM 包:
api/侧是 CommonJS,通过require('@librechat/data-schemas')命中exports的require条件加载dist/index.cjs,这解释了 package.json 中双格式构建与sideEffects: false的必要性。
而 Model 工厂把 mongoose 实例作为参数注入(而非包内单例),既避免了包与宿主 mongoose 实例不一致的经典陷阱,也让 jest.globalSetup.mjs 配合 mongodb-memory-server 的内存数据库集成测试成为可能——methods 目录下大量的 .spec.ts(如 user.methods.spec.ts、conversation.spec.ts、aclEntry.tenant.spec.ts)正是这一测试策略的产物。
七、构建与验证
README「Testing」一节要求新增实体时确保:类型可编译、模型可成功创建、方法处理边界情况(null 检查、校验)、索引与查询模式匹配。对应到工程操作上:
# 构建(clean + tsdown,产物 dist/ 同时含 .cjs 与 .mjs)
npm run build # 或 bun run b:build
npm run build:watch # 开发监听模式
# 测试(基于 jest + mongodb-memory-server)
npm run test # 交互模式 --watch
npm run test:ci # CI 模式 --coverage
验证顺序建议:先运行 npm run build 确认类型与产物无误,再运行 npm run test:ci 确认方法层与租户隔离等行为的测试通过;最后检查 createModels 的返回类型标注与 createMethods 的 AllMethods 交叉类型均已包含新实体。
八、小结
@librechat/data-schemas 用「types → schema → model 工厂 → methods」四层约定,把 LibreChat 从用户会话到 Agent/MCP/Skill、从令牌事务到审计日志的全部数据域收敛到一个强类型、可测试、可多租户隔离的包中。掌握本文的四层约定与新增实体七步流程后,你可以直接对照 models/index.ts 与 methods/index.ts 的现有实现,为该项目安全地扩展新的持久化实体。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00