nodejs-store
面向 MongoDB、MySQL、SQLite、PostgreSQL 与本地磁盘(local)的统一数据层 —— 用纯 JSON 定义模型,用 MongoDB 风格的 GQL 树语法查询,开箱即得基于角色的访问控制、计算列与软删除。
English: README.en.md
nodejs-store 让 Node.js 服务通过单一 schema 定义、单一查询方言同时对接 MongoDB(原生聚合)、MySQL、PostgreSQL、SQLite 与本地磁盘(local)。嵌套关系会编译为每个后端一条原生查询 —— 你永远不必手写 $lookup 或原生 SQL。
也在找 Python 版本?见
py-store(pip 包storepy)。两者都是共享 Rust 引擎rust-store之上的薄宿主。Store 家族(Gitee):
rust-store(共享 Rust 核心)·nodejs-store(本仓库 —— Node.js 宿主,npmnodejs-store)·py-store(Python 宿主,pipstorepy)
目录
- 它是什么
- 什么时候该用它
- 什么时候不该用它
- 与同类方案的对比
- 安装
- 快速开始
- 支持的后端
- 特性
- GQL 树查询
- 聚合
- 查询与写入 API
- 多数据源连接
- 权限上下文
- Schema 参考
- 向量检索
- 高级 API
- 事务边界
- 触发器
- 事务型能力
- 场景事例
- 性能
- 常见问题
- 相关项目
- 框架使用契约
它是什么
面向 Node.js 的轻量级、后端无关的数据层。你只需用纯 JSON 描述一次模型(fields、relations、computes、indexes、read/write 角色白名单)。库会据此推导出:
- 命令规划(GQL → Mongo 命令 JSON)—— 由 Rust 核心
rust-store-node执行, - 方言翻译(命令 JSON → 参数化 SQL),面向 MySQL / PostgreSQL / SQLite,
- 权限校验(schema 级 + 字段级读写、属主条件注入),
- 计算列、软删除归档,以及结果还原(扁平 JOIN 行 → 嵌套文档)。
MongoDB 是主方言:查询以 MongoDB 风格的 GQL 编写,三种关系型后端向它适配。这正是让同一份 schema 可同时运行在文档库与三种关系库上的原因。
与 py-store、rust-store 的关系
┌──────────────────────────────┐
Node.js ──▶ │ nodejs-store (npm, host) │ -┐
└──────────────────────────────┘ │ rust-store-node (napi-rs)
▼
┌───────────────────────────────┐
│ rust-store/core (pure logic) │
│ GQL · permissions · computes │
│ command planning · dialects │
└───────────────────────────────┘
▲
┌──────────────────────────────┐ │ rust-store-py (PyO3)
Python ──▶ │ py-store (pip, host) │ -┘
└──────────────────────────────┘
Rust 核心负责 GQL 解析、权限校验、计算列、命令规划与 SQL 方言翻译 —— 它从不接触数据库。宿主(nodejs-store、py-store)负责驱动 IO、回调与占位符替换。因此 Node.js 与 Python 之间不会出现行为漂移:只有一份实现。
什么时候该用它
当以下任一情形符合你的处境时,就该用 nodejs-store:
- 一套代码,多个数据库。 同一服务在开发环境跑 MongoDB、生产环境(或按租户)跑 PostgreSQL,而你不想维护两套数据访问层。
- 需要嵌套/关系读取,却不想写
$lookup或 JOIN。 Order → items、Course → lessons、User → orders —— 都只在 schema 中声明一次,并在单条查询内解析。 - 正在构建管理后台或内部 CRUD 服务,想要 schema 驱动的 CRUD、软删除、计算列与角色校验,而不引入完整 ORM。
- 需要行级/字段级访问控制。 按角色的白名单、
guest永不写、creator依据doc.createdBy校验属主,属主条件会自动注入查询。 - 正在构建 AI/自然语言数据问答层。 本库在设计时就考虑了 AI 查询宿主:
buildPipeline()可在不执行的情况下暴露规划后的查询,降级/不可下推路径会发出结构化反馈事件而非静默失败。参见配套技能text-to-query。 - 正在 MongoDB 与 SQL 之间迁移,并希望在迁移期保持一套查询语法。
- 多租户 SaaS。 一份 schema 定义,N 个租户:把 schema 定位到
(source, database, schema, collection),并在执行时用{ source, database, schema }覆盖把任意查询/写入重新定向到目标租户。
典型的具体场景(完整演练见 doc/use-cases/):
| 场景 | nodejs-store 为何合适 |
|---|---|
| 每租户独立 schema/数据库的多租户 SaaS | 每租户一个 database + 运行时路由覆盖,一套 schema |
| 管理后台 / 内部工具 | schema 驱动 CRUD、软删除、计算列、RBAC |
| 今天 MongoDB,明天 PostgreSQL | 同一 GQL + 同一 schema,只有数据源变化 |
| AI 数据问答 / text-to-query 智能体 | 仅规划的 buildPipeline、确定性的命令 JSON、反馈事件 |
| 同一产品中混用 SQL + Mongo | 跨源查询,SQL 原生下推、Mongo 走内存联邦 |
| 审计友好的 CRUD | 每个 schema 自动获得一个 <Model>Deleted 归档表/集合 |
什么时候不该用它
明确边界能为你省下时间:
- 你想要带迁移引擎的完整 ORM。
nodejs-store是数据层,不是迁移工具。它可以读取 SQL 后端的物理结构(syncSchema→ introspection),但从不把 DDL 写回。请与你自选的迁移工具搭配使用。另有一个可选的generateDdl(),可从已注册 schema 渲染出CREATE TABLE文本 —— 纯文本,绝不连接或写入数据库。 - 你需要带类型安全、自动生成的客户端。 schema 是运行时 JSON,而非 TypeScript 类型。你获得的是灵活性与跨语言一致性(同一份 schema 在 Node 与 Python 中都可用),而不是编译期类型推断。
- 你只用一种数据库,且几乎不做关联。 直接用裸驱动(或只针对单一数据库的 ODM/ORM)会更简单。
- 你需要原生聚合逃生舱。 用户
$pipeline透传已移除 —— 含$pipeline的 GQL 一律在解析期显式失败。原生逃生口只保留显式入口:store.aggregate()(严格 opt-in,默认禁用,需init(conn, { allowNativeAggregate: true })显式开启)、executeNative()(Mongo 原生聚合)与executeRaw()(原生 SQL)。日常查询请使用$condition/$group/$having/ 关系;任何无法安全翻译的内容都会显式失败,而不会静默降级。 - 你在 SQLite 的高并发热路径上。 SQLite 执行器按设计使用同步的
better-sqlite3驱动 —— 调用会阻塞事件循环。此类场景请优先使用 MySQL / PostgreSQL / MongoDB,或把 SQLite 隔离到独立进程。
与同类方案的对比
以下为总体定位,并非基准测试 —— 请始终以各工具当前文档为准。
| nodejs-store | Mongoose | Prisma | TypeORM / Sequelize | Drizzle | |
|---|---|---|---|---|---|
| 主要形态 | JSON schema + GQL 数据层 | ODM(MongoDB) | Schema DSL + 生成的客户端 | 装饰器/实体 ORM | TypeScript SQL 构建器 |
| 后端 | MongoDB、MySQL、SQLite、PostgreSQL、local | MongoDB | PostgreSQL、MySQL、SQLite、SQL Server、MongoDB、CockroachDB | MySQL、PostgreSQL、SQLite、MSSQL、Oracle(+ MongoDB) | PostgreSQL、MySQL、SQLite、… |
| 跨 Mongo 与 SQL 的统一查询方言 | ✅(MongoDB 风格 GQL) | ➖(仅 Mongo) | ➖(每个 provider 一个客户端) | ⚠️(Mongo 模型与 SQL 实体不同) | ➖(仅 SQL) |
| 单条查询内的嵌套关系读取 | ✅ 声明式 relations → $lookup / JOIN |
✅ populate() |
✅ include |
✅ relations | ⚠️ 手动 join |
| 内置角色 / 字段级 RBAC + 属主注入 | ✅ | ➖ | ➖(通过扩展) | ➖ | ➖ |
| 读时计算列(同步 / 异步 / 关系聚合) | ✅ | ➖(getter) | ➖ | ➖ | ➖ |
| 自动置备软删除归档表 | ✅ | ➖ | ➖ | ➖ | ➖ |
| 迁移 / DDL 引擎 | ➖(introspection 只读;可选 generateDdl 文本) |
➖ | ✅ | ✅ | ✅ |
| 静态类型生成 | ➖(运行时 JSON,跨语言一致) | ➖ | ✅ | ⚠️(装饰器 + TS) | ✅ |
| Node 与 Python 共享原生核心 | ✅(Rust rust-store) |
➖ | ➖ | ➖ | ➖ |
与具体库的差异
仅为定位说明,基于撰写时这些项目的公开文档 —— 请以你自己的需求为准进行核实。
- vs Mongoose —— Mongoose 仅支持 MongoDB。
nodejs-store使用类似的 MongoDB 风格查询语法($gt、$or、$set、$inc),但同一条查询也能原样跑在 MySQL、SQLite 与 PostgreSQL 上。 - vs
mongoosql-core—— 精神上最接近:它同样能在 MongoDB、PostgreSQL 与 MySQL 上运行 Mongoose 风格的查询。nodejs-store还额外面向 SQLite,内置 schema 级权限(角色/字段白名单,外加creator属主条件注入)、读时计算列(fn/asyncFn/ 关系agg)、自动置备的<Model>Deleted软删除归档,并与 Python 宿主共享同一个 Rust 引擎,因此 Node.js 与 Python 不会产生漂移。 - vs
unsql——unsql从普通 JavaScript 对象为 MySQL、PostgreSQL 与 SQLite 生成 SQL。它不面向 MongoDB,且只是一个查询/CRUD 辅助工具,而非带权限与计算列的 schema 驱动数据层。 - vs Prisma —— Prisma 是 schema DSL 加生成的客户端,配有迁移引擎与编译期类型。
nodejs-store是运行时 JSON schema,不承担迁移引擎职责(仅通过 introspection 读取物理结构,generateDdl()也只渲染CREATE TABLE文本而不触碰数据库),也不生成类型 —— 以此换取一套横跨文档库与三种关系库的查询方言。 - vs TypeORM / Sequelize / Drizzle —— Sequelize 与 Drizzle 仅支持 SQL;TypeORM 把 MongoDB 与其 SQL 实体分开建模。
nodejs-store以 MongoDB 为主方言,并把同一份 GQL 编译为其余三种后端的 SQL。
一句话:想要编译期类型与迁移就用 ORM;想要一份运行时 schema + 一套横跨 MongoDB 与 SQL 的查询方言,并内置 RBAC 与计算列,就用 nodejs-store。
安装
npm install nodejs-store
需要 Node.js 18+,以及一个受支持的后端(MongoDB / MySQL / SQLite / PostgreSQL / 本地磁盘)。
快速开始
const { MongoClient } = require('mongodb');
const { init, store } = require('nodejs-store');
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
await init(client.db('mydb')); // 幂等地为已注册的 schema 创建索引
// 注册 schema(纯 JSON)
store.register({
name: 'Post', // GQL 中使用的模型名
collection: 'posts', // 可选,默认等于 name
idPrefix: 'PT', // 字符串 _id:前缀 + base36 时间戳 + 随机串
fields: {
title: { type: 'string', default: '' },
status: { type: 'string', default: 'draft' },
tags: { type: 'array', default: [] },
},
computes: {
statusLabel: {
type: 'string',
depends: ['status'],
fn: (doc) => (doc.status || '').toUpperCase(),
},
},
indexes: [{ keys: { status: 1, createdAt: -1 } }],
});
// 写入 —— 只写用户数据;默认值在读时填充
const doc = await store.insert('Post', { title: 'Hello' });
// 查询 —— GQL 树语法,值通过 @key 从 params 引用
const items = await store.query(
'Post($condition:@c0,$sort:@s1,$limit:@l) { title, status, statusLabel }',
{ c0: { status: 'draft' }, s1: { createdAt: -1 }, l: 20 },
);
同一份 schema、同一条查询可原样跑在 PostgreSQL 上 —— 只有 init() 的数据源不同:
await init({ default: { kind: 'postgres', exec } }); // exec:你的 pg 连接池适配器
const items = await store.query('Post($condition:@c0) { title, status }', { c0: { status: 'draft' } });
支持的后端
| 后端 | 说明 |
|---|---|
| MongoDB | 原生聚合管道(find/aggregate/$lookup) |
| MySQL | 参数化 SQL,information_schema introspection |
| SQLite | 参数化 SQL,sqlite_master + PRAGMA introspection。同步驱动(better-sqlite3):调用按设计阻塞事件循环 —— 高并发热路径请优先 MySQL/PostgreSQL/MongoDB,或把 SQLite 隔离到专用进程 |
| PostgreSQL | 参数化 SQL($n),支持 RETURNING |
| local(本地磁盘) | 集合以 JSON 文件落盘,core 本地求值器执行(无 SQL、无驱动依赖);见本地磁盘数据源(local) |
GQL 树查询会编译为每个后端一条原生查询(local 源除外 —— 命令在 core 本地求值器内直接求值)—— 再也不必手写 $lookup 或原生 SQL。
本地磁盘数据源(local)
零外部服务、零原生 DB 引擎:集合以 JSON 文件落盘,命令在 Rust core 的本地求值器内直接执行(语义与 MongoDB 驱动一致)。
const { init, local } = require('nodejs-store');
await init({ default: local.connect({ dir: './data/store' }) });
- 用法:
local.connect({ dir })返回连接描述符(kind: 'local'),与 Mongo/SQL 连接一样传给init()/setConnections()。 - 落盘格式:
<dir>/<物理集合名>.json(文档数组);写入先写.tmp中间文件再原子rename。 - 事务语义:快照隔离 —— 事务期读写内存快照,
commit()整目录落盘、rollback()丢弃,天然原子。 - 护栏:单集合上限 100,000 文档(超限显式报错,不静默截断);不建索引(仅顺序扫描),声明
schema.indexes即发local_indexes_ignored反馈事件。 - 并发限制:仅单进程 —— 进程内写经目录锁串行化;跨进程并发不在 v1 保证范围。
特性
- 纯 JSON schema,零代码 —— 一个模型就是一个对象:fields、relations、computes、indexes。
- GQL 树查询 → 一条原生查询 —— 嵌套关系在单条查询中解析;再也不必手写
$lookup。 - 归一化聚合 —— 根级
$group/$having与关系聚合谓词(semi/anti-join)在同一份 GQL 中,下推到全部五种后端。 - 读时默认值与计算列 —— 写入只存用户数据;读取时填充默认值并运行
fn/asyncFn/ 关系agg计算列。 - 智能持久化 ——
mutation()依据_id+ 唯一索引自动识别 upsert,并递归填充关系子文档。 - 内置软删除 —— 每个 schema 自动注册一个
<Model>Deleted归档集合/表;remove()先归档再删除。 - 权限上下文 —— 基于
AsyncLocalStorage的角色(super_admin/admin/guest/creator…)、schema/字段级读写白名单、自动属主条件注入。 - 多数据源 & 多租户 —— 通过
(source, database, schema, collection)定位 schema;按请求用路由覆盖重新定向。 - 异步优先,Rust 核心 —— 基于
mongodbNode.js 驱动与共享的 Rust 核心(含 SQL 方言)。 - mutation 关系谓词 ——
update/remove按关联表字段过滤,下推到全部五个后端(此前 MongoDB 侧是静默 no-op)。 - 自增主键 ——
_id声明{ type: 'int', strategy: 'autoincrement' }即用数据库自增整数 ID;做不到自增的场景显式报错。 - 索引 DDL ——
schema.indexes编译为真实CREATE [UNIQUE] INDEX语句(按后端、逐字节一致);生成器仍只产文本。
GQL 语法
Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
field1, field2, obj.subField,
Relation($condition:@c2,$sort:@s3,$limit:@l2) { f3, Nested { f4 } }
}
- 值来自 params 对象:
{ c0: {...}, s1: {...} }。 - 对象的子字段使用点号表示法;关系在 schema 中声明(
type: 'many' | 'one')并自动解析 —— 不要手写$lookup。 many关系返回数组(为空时为[]);one关系会合并进父文档(缺失时为null)。- 关系级
$sort/$skip/$limit是按父级的 top-N(每个父级各自取窗口;在 SQL 上翻译为窗口函数)。 - null 与「缺失」是有意的三态契约:
{ field: null }仅匹配显式 null;{ field: { $eq: null } }匹配 null 或缺失;{ field: { $exists: false } }仅匹配缺失。
从 4.x 升级到 5.0
破坏性变更:用户
$pipeline透传已移除 —— 包含$pipeline的 GQL 现在在解析期显式失败(ERR_GQL_PARSE:),而不再被静默忽略,也不再按档位放行。
- 保留键已全部解除。
type/read/write/fields/localField/foreignField在 5.0.0 均可作字段名;4.8.0 下注册即抛ERR_NAME_CONFLICT。字面保留名仅剩_id与_score。 store.aggregate()在 4.8.0 中不存在。 5.0.0 恢复为严格 opt-in 逃生舱(见事务与原生 SQL):默认禁用(ERR_NATIVE_AGGREGATE_DISABLED),需init(conn, { allowNativeAggregate: true })开启。- null 条件遵循三态契约。 请逐条核对既有查询的意图:要「null 或缺失」用
{ $eq: null },要「仅显式 null」用裸null,要「仅缺失」用{ $exists: false }。 $group累积器仍只支持$count/$sum/$avg/$min/$max。$sum: { $cond }、$addToSet、$last会显式报错;需改写查询或把逻辑下沉到应用层。
聚合
归一化聚合内置于 GQL —— 日常查询不经过单独的聚合 API、不写原生管道。原生聚合仅通过显式逃生舱可达:store.aggregate()(opt-in)、executeNative() 与 executeRaw()。
根级 $group + $having(GROUP BY / HAVING):
const rows = await store.query(
'Course($condition:@c0,$group:@g0,$having:@h0,$sort:@s0,$limit:@l0){ status, n, total }',
{
c0: { status: { $ne: 'deleted' } },
g0: { by: ['status'], agg: { n: { $count: '*' }, total: { $sum: 'price' } } },
h0: { n: { $gt: 1 } },
s0: { total: -1 },
l0: 20,
},
);
- 白名单算子:
$count/$sum/$avg/$min/$max。该集合是封闭的:$sum: { $cond }、$addToSet、$last均不支持,请求即显式报错。 - 固定执行顺序:
$condition(WHERE)→$group(GROUP BY)→$having(HAVING)→$sort→$skip/$limit→ projection。 - 省略
by(或传[])即对全表做单一分组;空输入情况仍返回一行($count→0,其余 →null)。
关系聚合谓词(semi / anti-join) —— 按关系的聚合过滤父级,而不会发生行扇出:
await store.query('Product($condition:@c0,$sort:@s0){ _id, name }', {
c0: {
$and: [
{ status: 'onSale' },
{ orders: { $count: { $gt: 3 } } }, // 订单数 > 3
{ $not: { orders: { $sum: { $of: 'amount', $gt: 10000 } } } }, // 不是大客户
],
},
s0: { name: 1 },
});
在 SQL 上翻译为 EXISTS / NOT EXISTS,在 MongoDB 上翻译为 $lookup + $match。
关系滚动计算列 —— 在 schema 中声明一次,按名称请求:
computes: {
itemCount: { type: 'int', agg: { $count: 'items' } }, // 关系为空时为 0
itemsTotal: { type: 'float', agg: { $sum: 'items.qty' } }, // 关系为空时为 null
}
查询与写入 API
const items = await store.query(gql, params); // Array
const one = await store.queryOne(gql, params); // object | null
const page = await store.queryWithCount(gql, params); // { items, total, hasMore, page, pageSize }(pageSize 上限 5000)
const exists = await store.exists('Post', { _id: pid });
const n = await store.count('Post', { status: 'active' });
const doc = await store.insert('Post', { ... }); // 自动 _id / createdAt / updatedAt
const docs = await store.insertMany('Post', [{ ... }, ...]);
await store.update('Post', { _id: pid }, { status: 'live' }); // 普通字段 → $set
await store.update('Post', { _id: pid }, { $inc: { views: 1 } }); // 以 '$' 开头的键作为操作符透传
await store.updateMany('Post', { type: t }, { status: 'live' });
const r = await store.remove('Post', { _id: pid }); // 先归档到 <collection>_deleted
await store.mutation('Post', { ... }); // 智能 upsert + 递归关系子文档
await store.upsert('Post', { code: 'A1' }, { ... }); // 显式条件的 upsert(不做关系处理)
注意:
null/undefined值在持久化前会被剔除;_id无法通过update修改。createdAt/updatedAt(毫秒)由框架维护 —— 不要手动设置。queryWithCount接受page/pageSize(推荐)或传统的$skip/$limit参数。- 带空条件(
{}、null、{ "$and": [] })的updateMany/remove会被直接拒绝 —— 它绝不会退化为全表写入。
事务与原生 SQL
async function transfer() {
const rows = await store.executeRaw(
'default', 'SELECT * FROM accounts WHERE _id = ? FOR UPDATE', [accId]);
await store.executeRaw(
'default', 'UPDATE accounts SET balance = ? WHERE _id = ?', [newBalance, accId],
true);
}
await store.transaction('default', transfer);
store.transaction(source, fn)在单个 SQL 源上开启事务作用域:fn内的每个executeRaw/ CRUD 调用都落到该源的事务连接,commit/rollback作为一个整体(复用内部的runInTransaction)。Mongo 源按运行时能力探测(replica set / sharded)以 session 事务执行;standalone 或探测失败则按原样执行fn并发mongo_transaction_unsupported(deployment: standalone|unknown)—— 绝不假装已原子。不支持事务(未实现withTransaction)的执行器亦按原样执行,并发出一条transaction_not_atomic反馈(允许降级,绝不静默假装已事务化)。同源嵌套 transaction 会开保存点(内层失败只回滚本层);句柄无保存点原语时降级并入外层并发nested_savepoint_unsupported。store.executeRaw(source, sql, params, isWrite)执行原生 SQL,绕开 GQL 解析与方言翻译。占位符沿用各后端原生风格:MySQL / SQLite 用?,PostgreSQL 用$1..$n。仅限 SQL 源 —— Mongo 源会抛出RawSqlError(store.RawSqlError)。store.executeNative(source, collection, pipeline, options)在 Mongo 源上执行原生聚合管道(SQL 侧executeRaw的对位逃生舱):管道原样下发,options使用驱动原生键;事务 / session 内自动注入 session;仅限 Mongo 源 —— SQL 源抛NativeCommandError并指引executeRaw。返回{ rows }。store.aggregate(schemaName, pipeline, options)是executeNative的 schema 名版本(5.0.0 恢复;4.8.0 不存在该 API):解析schemaName → (datasource, collection)后走同一执行路径。默认禁用 —— 未用init(conn, { allowNativeAggregate: true })开启时抛ERR_NATIVE_AGGREGATE_DISABLED;不注入权限过滤、不做 computes 回填;未知 schema 抛ERR_AGGREGATE_SCHEMA。isWrite=false(默认)返回{ rows, affectedRows }含结果集行;isWrite=true返回影响行数。
会话(Session / 工作单元)
await store.session(async (s) => {
await s.insert('Order', { ... });
await s.update('Account', cond, { ... });
await s.executeRaw('pg_main', 'SELECT ... FOR UPDATE', [1]);
});
- 会话内同一 SQL 源的全部命令落到同一事务连接:退出统一提交,异常统一回滚;
- 惰性开事务:会话内没有任何命令时不占用连接;
- 跨源写 fail-closed:同一会话内写入了 ≥2 个数据源时,退出先全部回滚再抛
NonAtomicWriteError(跨源无分布式事务,绝不提交半截); - Mongo 源按运行时能力探测(replica set / sharded)事务化;standalone 或探测失败则按原样执行(非原子),并发出一条
mongo_transaction_unsupported(deployment: standalone|unknown)反馈; - 会话可嵌套:内层作用域在已有事务上开保存点(
SAVEPOINT sp_<n>),退出按成败RELEASE(成功)/ROLLBACK TO+RELEASE(失败)——内层失败只回滚内层,外层可继续; 事务句柄未提供保存点原语时降级并入外层,并发出一条nested_savepoint_unsupported反馈。
DDL 生成
let sql = store.generateDdl('mysql'); // 所有已注册模型
sql = store.generateDdl('postgres', ['Course', 'CourseDeleted']);
store.generateDdl(backend, names) 把一个已注册 schema def 映射为一条 CREATE TABLE —— 是 syncSchema()(只读取)的逆操作。生成器是纯文本:它绝不连接、也绝不写入数据库(铁律 6 依然成立)。
- 只有标量字段成为列;
object/array字段不建列。 - 每张表都会获得
__present哨兵列;timestamps模型还会获得createdAt/updatedAt;<collection>_deleted归档表与其它已注册 def 一样生成。 - 已声明的
schema.indexes会渲染为CREATE [UNIQUE] INDEX idx_<table>_<cols>(在 MySQL / PostgreSQL / SQLite 上逐字节一致)。运行期 SQL 后端仍仅把索引保留为元数据 —— 库绝不向数据库执行 DDL。 - MySQL 的
__present为VARCHAR(255);若某 schema 的 present 令牌串会超限,则发出ddlPresentOverflow反馈事件,而非静默失败。
多数据源连接
每个 schema 由 (source, database, schema, collection) 定位 —— schema 仅适用于
PostgreSQL 源;该落点在 registry 内必须全局唯一(重复注册会抛错,而不是静默错路由)。
source——init({...})中的连接键(默认"default")。database—— 连接内的数据库:Mongo 库名、PG / MySQL database、SQLite 附加库。schema—— PostgreSQL 的 schema(仅 PG 源);其它后端为null。collection—— 表/集合名。
定义不携带落点字段:schema 定义文件不声明 source / database / schema(其
collection 只是逻辑表/集合名)。落点由「定义目录布局 + 连接配置」解析:
Location: directory semantics + connection config (definitions carry no location)
A schema definition file contains no location fields (no source / database / schema; namespace is removed). Location is resolved from the definition directory layout plus the connection config:
- Under the definitions root
<defs-root>/: the first directory level is thedatabase; PostgreSQL adds a second level forschema(Mongo / MySQL / SQLite have no such level); deeper levels are free-form and flattened at load time (no hierarchy semantics). - The connection config (
store.config.json) declaressources(kind+databases) anddefs;kinddecides whether that database directory is read one level deeper forschema. - Location fields are
source/database/schema(PG only) /collection; the wordnamespaceis removed. - Same-named schemas: exactly one primary (no
replica); the rest declare{ "name": "...", "replica": true }, add only a link, and must not repeat the structure. Zero or two-or-more primaries is an error. - A duplicated
namewithin one load batch is an error and the service does not start; re-loading the samenameacross versions bumps its version by 1. - Writes are synchronized within a single connection, across the primary plus all links, in one transaction; a write spanning a cross-connection link is explicitly rejected or degraded with a feedback event (never silent).
// 多个 Mongo 服务器:每个连接一个 source
await init({ mongo_main: db, pg_a: { kind: 'postgres', exec } });
// 同一个 MongoClient 服务多个数据库:选择 database
await init({ cluster: client });
store.register({ name: 'User', collection: 'users', datasource: 'cluster', database: 'tenant_42', ... });
// SQL 跨 database 的 join 会原生下推("db_a"."t" JOIN "db_b"."t");
// 只有 Mongo 的跨库关系会退回内存联邦。
多租户路由覆盖 —— 一份 schema 定义,N 个租户。任意查询/写入都接受
一个 { source, database, schema } 覆盖参数,在执行时把命令重新定向(权限
与计算列仍按结构 schema 判定):
await store.query('User($condition:@c0){...}', params, { database: 'tenant_42' });
await store.insert('Order', data, { source: 'pg_cluster', database: 'tenant_7' });
routeOverride 是受信的服务端参数 —— 它不做来源校验,因此把用户可控输入
透传进来,会让调用者把命令重定向到其他租户的 source/database/schema(CWE-639
授权绕过面)。绝不要把原始请求数据传到这里。
传统的单库用法(init(db) + 不含 datasource/database 的 schema)保持不变:
命令携带 source: 'default'、database: null、schema: null。
权限上下文
// 每个请求设置一次(在中间件/路由层)
store.setContext({ userId: uid, roles: ['editor'] });
// 嵌套安全的作用域角色
store.scopedRoles(['viewer'], () => store.query(gql, params));
// 内部/定时任务 —— 绕过权限校验
await store.runAsInternal(() => store.remove('Post', { _id: pid }));
super_admin/admin/internal角色放行一切;其他角色按 schema 级与字段级read/write白名单校验;guest永不写。creator是一个伪角色,依据doc.<归属键> === ctx.userId解析(归属键由 schemaownerKey声明,缺省createdBy,可自定义如_id);授予它的 schema 会自动在查询上注入属主条件,并在 update/remove 时做属主校验。- 未设置上下文 → 权限校验关闭(向后兼容)。
- 拒绝访问时抛出
store.PermissionError(status = 403)。
失败即安全模式(可选开启)
“无上下文”既可能表示系统调用,也可能表示调用方忘记设置上下文 —— 默认情况下 后者会静默通过所有校验(fail-open,为向后兼容而保留)。对安全性敏感的宿主, 可在启动时一次性开启上下文强制要求:
store.setRequireContext(true);
// 此后每个没有上下文的查询/写入都会抛出 `ERR_NO_CONTEXT:...`
// 内部任务必须显式声明:
await store.runAsInternal(() => store.remove('Post', { _id: pid }));
runAsInternal 会把该次调用标记为 { internal: true },其语义与”缺失上下文”
不同,且总是放行。setRequireContext(false) 恢复默认行为。
Schema 参考
{
name: 'Order',
collection: 'orders',
idPrefix: 'OD',
timestamps: true, // 默认:自动维护 createdAt/updatedAt(毫秒)
fields: {
_id: 'string', // 简写
title: { type: 'string', default: '' },
meta: { type: 'object', default: {}, fields: { ... } }, // 嵌套对象字段
bidders: { type: 'array', default: [] }, // 数组字段 —— 内联 agg 目标
},
relations: {
items: { model: 'OrderItem', type: 'many', localField: '_id', foreignField: 'orderId' },
},
computes: {
total: { type: 'float', depends: ['amount'], fn: (d) => d.amount * 1.1 },
itemCount: { type: 'int', agg: { $count: 'items' } }, // 关系聚合
biddersCount: { type: 'int', depends: ['bidders'], agg: { $size: 'bidders' } },
maxBid: { type: 'float', depends: ['bidders'], agg: { $max: 'bidders.amount' } },
bidderCount: { type: 'int', depends: ['bidders'], agg: { $countDistinct: 'bidders.bidderId' } },
},
indexes: [
{ keys: { status: 1 } },
{ keys: { code: 1 }, options: { unique: true } },
],
read: ['editor', 'viewer'], // 可选的 schema 级角色白名单
write: ['editor'],
ownerKey: '_id', // 可选:`creator` 伪角色的归属键;缺省 'createdBy'
}
类型:string | int | long | float | double | boolean | array | object | date | any。
几条需要提前知道的边界规则(全部显式失败,绝不静默降级):
- 直接对数组字段、整个对象字段或对象点路径做过滤,在任何后端都会被拒绝 —— 请改用
relations建模跨实体语义。 - 关系谓词仅支持一层关系;形如
orders.items.price的路径会被拒绝。 - 不可读的关系是错误,而不是静默的
false。
内联数组聚合
agg 路径首段命中 fields 声明的数组字段(而非关系)时,对文档自身的数组做聚合 —— 编译进同一条管道,不新增查询阶段:
| 算子 | 路径 | 空数组 / 缺字段 |
|---|---|---|
$size |
数组字段本身 | 0 |
$sum |
数组.元素字段 |
0 |
$avg / $max / $min |
数组.元素字段 |
null |
$countDistinct |
数组.元素字段 |
0 —— 去重并剔除 null 后计数 |
规则(违规在注册期显式失败,绝不静默降级):
depends必须包含路径首段字段。- 数组元素仅一层:
bidders.amount合法;再往数组深层嵌套会被拒绝。 $count仅限关系 —— 数组请用$size。- 仅 MongoDB 与 local(进程内)求值可用;SQL 后端显式报错。
Naming: freeform definitions, system-directed translation
Definitions (collection, fields, referenced relation fields, computed-column keys, fnRef values, index names) may use any style; the engine translates them to the target style. Contract keys (fnRef, localField, foreignField, asyncFn, type, …) and the schema name are never translated.
| Target | Style | Example (orderTotal) |
|---|---|---|
| MySQL / PostgreSQL / SQLite (physical) | snake_case | order_total |
| MongoDB (physical) | camelCase | orderTotal |
| Node.js / Java / C# / Rust (code; computed columns follow) | camelCase | orderTotal |
| Go (code; computed columns follow) | PascalCase (must be exported) | OrderTotal |
| Python (code; computed columns follow) | snake_case | order_total |
Canonicalization (single implementation core::naming, re-exported by the bindings; hosts must not re-implement it): split on _, -, ., space and at lower/digit-to-upper boundaries; a trailing uppercase in a run followed by a lowercase starts the next token (HTTPServer -> [http, server], userID -> [user, id]); digits stay inside a token (order2Items -> [order2, items]). Reassembly: snake = t1_t2, camel = t1T2, pascal = T1T2.
Two logical names in one schema that canonicalize equal (orderTotal vs order_total) is an error ERR_NAME_CONFLICT: and the service does not start (never silently overwritten). The fields key space is fully open — no field name is reserved: contract keys (type, default, …) live inside a field’s definition object at a different level, so fields: { type: { type: "string" } } is valid. Literal reserved names are only _id (framework primary key) and _score (vector score column).
Computed columns: fnRef binding by composite name + canonical match
Computed columns live at the schema top level, computes: { <key>: { type, fn | asyncFn | agg, fnRef?, depends?, read? } } (fn / asyncFn / agg are mutually exclusive).
- The logical
fnRefdefaults to<schema.name>.<computed-column key>(generated, never hand-written); sincenameis globally unique, thefnRefis globally unique too. - Host implementations bind by canonicalization: both the implementation’s name in the host language style and the schema’s logical
fnRefare canonicalized to token sequences and compared. So Node’sorderAmountLabeland Python’sorder_amount_labelbind to the same logical computed column. - Reusing one implementation across schemas: write an explicit shared name (e.g.
"fnRef": "common.moneyLabel"); naming goes from required to optional. - Every declared
fnRefmust have an implementation, otherwise the service fails to start withERR_FN_MISSING.
向量检索
向量就是带维度与度量声明的普通字段值:你产出 embedding,存储负责持久化、排序并回传评分。 库内不内置任何 embedding 模型。
schema 声明
{
name: 'Artwork',
collection: 'artworks',
fields: {
title: { type: 'string', default: '' },
caption: { type: 'string', default: '' },
embedding: { type: 'vector', dim: 1024, metric: 'cosine' }, // 'cosine' | 'l2' | 'ip'
embeddingHash: { type: 'string', default: '' }, // 可选:回填摘要
},
indexes: [
// 声明原生近似索引意图(pgvector / Atlas Search)
{ keys: { embedding: 'vector' }, options: { metric: 'cosine', method: 'hnsw' } },
],
}
type: 'vector'必须声明整数dim与metric: 'cosine' | 'l2' | 'ip'之一;dim/metric非法在注册期即报错,不拖到查询期。- 两个上限不可混用:存储上限 16000 维;原生索引上限更低且分后端——pgvector HNSW /
IVFFlat 为 2000 维,MongoDB Atlas
vectorSearch为 4096 维。DDL 生成对超限索引显式报错, 不静默跳过。 - 向量索引项只接受单字段:
{ keys: { <字段>: 'vector' }, options: { ... } };选项为metric、method(hnsw|ivfflat|flat)与 Atlas 的indexName。 - 向量为
null的行(含迁移前存量行)不参与评分。
查询算子
const rows = await store.query(
'Artwork($condition:@c){ _id, title, _score }',
{ c: { embedding: { $near: { $vector: [0.12, -0.03 /* ... */], $limit: 10 } } } },
);
$near只接受三个键:$vector(必填查询向量)、$limit(必填 TopK,正整数)、$minScore(可选,_score下限)。其他键一律拒绝。metric由字段声明决定,不是$near的参数。三种度量统一为「越大越相似」:cosine为余弦相似度,ip为内积,l2为1 / (1 + 欧氏距离)。- 过滤条件写在同一个
$condition内、与$near同级并列,例如{ status: 'public', embedding: { $near: { ... } } }。 _score按需投影:必须在投影里显式写出;不含$near的查询永不返回_score键。- 向量长度与
dim不符、零向量、缺$limit在所有后端都报错。
降级语义
store.capabilities().vector 为静态能力报告:{ supported, native, backend, metrics, maxDim }。
native: true表示该目标能跑近似索引(pgvector 已安装,或声明了 Atlas Search 向量索引)。native是目标的静态属性,不会因为某次查询回退而改变。- 没有原生索引时——MySQL、未启用
vec0的 SQLite、未声明 Atlas Search 索引的自建 MongoDB, 以及嵌入式local/memory载体——查询仍按应用层精确检索执行,同时宿主记录vector_degraded降级事件。降级必须可观测,禁止静默。 - PostgreSQL 带过滤(
$condition内除$near还有条件)回退为精确扫描:pgvector 近似索引 属后过滤,会破坏 ACL 与 TopK 正确性。 - 三种观测方式:
store.withDegradedScope(fn)作用域内读store.degradedStatus();反馈通道的vector_degraded事件;store-api的x-store-degraded响应头。事件字段为type / code / layer / backend / field / metric / message / hint。
迁移步骤
- 存量表不会被隐式改列。须显式加列并重新注册字段:
generateMigration(...)产出ALTER TABLE ... ADD COLUMN embedding vector(1024)(vector 列不接受DEFAULT;存量行在回填前 保持null)。 - 已存在字段的
dim/metric变更不作为迁移支持——须显式重建列与其索引。 - 回填用
store.reindexVectors(schema, { field, hashField, sourceFields, embed }):按_id锚点分页(不用skip),对sourceFields做sha256摘要,只对摘要变化的行重新 embed。force: true全量重算;dryRun: true只报告不调embed。向量与摘要同一次 update 写入。 - 增量场景用
store.registerVectorReindex(cfg)注入<Schema>.vectorReindex触发器,按需刷新 单行。 - DDL 生成只打印索引语句,存储从不执行它。大表须在业务外执行(PostgreSQL 用
CREATE INDEX CONCURRENTLY)。
高级 API
以下所有内容都可从导出的 store 单例或其重导出的模块访问。以 ? 为前缀的选项
是可选的。
store.buildPipeline(gql, params?)
底层解析 —— 将 GQL 编译为命令计划而不执行它,返回
{ tokens, ast, pipeline, projection }。适用于调试查询形状、断言下推行为,
或构建自定义工具(例如必须在运行前展示并校验计划的 AI 查询智能体)。
此处不会应用权限 / 计算列。
const plan = store.buildPipeline('Post($condition:@c0){ title }', { c0: { status: 'draft' } });
console.log(plan.pipeline);
store.syncSchema(opts)
把一个 SQL 后端的物理结构拉取进 registry
(introspect → schemaFromRows → mergeSchema(overlay) → register)。它只读取
结构 —— 从不把 DDL 写回数据库。
| 选项 | 类型 | 含义 |
|---|---|---|
backend |
'mysql' \| 'postgres' \| 'sqlite' |
必填 |
driver |
object | 必填;建议使用只读账号 |
introspectOptions |
object | 透传给 introspection(例如 PG 的 schema) |
overlay |
Array |
叠加合并的本地 schemaJSON(权限 / 计算列 / 覆盖) |
datasource |
string | 把所有合并后的 def 绑定到该 source |
database |
string | 把所有合并后的 def 绑定到该 database |
registerDefs |
boolean(默认 true) |
false = 返回 defs 但不注册 |
返回合并后的 schemaJSON[]。
const defs = await store.syncSchema({
backend: 'postgres', driver: pgPool, overlay: [Post], datasource: 'pg_a',
});
store.setFeedbackSink(fn)
接管用于兜底 / 降级 / 拦截事件的统一反馈通道。sink 接收一个事件对象;传入
null(或非函数)则回退到默认的 stderr 打印器。
store.setFeedbackSink((e) => logger.warn({ code: e.code }, e.hint));
// 事件形状:{ type, code, layer, message, hint, ... }
// type federation_degraded | sql_pushdown_unsupported | ...
// code crossSourceSort | pushdownUnsupported | ...
// layer federation | dialect | ...
不可下推的命令还会抛出 PushdownUnsupportedError —— 捕获它即可把该片段
改投到某个 Mongo 源重跑。
store.ask(question, opts)
AI 问数——自然语言查询(L1,只读)。问题与一份按权限过滤的 schema 摘要
(store.describeForAi)一起交给可插拔的 LLM,LLM 必须返回唯一的
{"gql","params"} JSON 对象;该查询再经加固的 text2query 档位规划并执行
(只读、行数/深度硬限、路由覆盖禁用)。结构化失败会回喂 LLM 重试(至多
maxRetries 轮);耗尽抛 AskExhausted——问数失败就是失败,不静默降级、
不返回空结果。
LLM 输出永远当不可信输入:护栏(档位、上下文强制、只读)在服务端硬编码, 模型不可触达。
| 参数 | 类型 | 含义 |
|---|---|---|
llm |
string \| Function |
必填;注册名(见下方 llm 注册表)或符合 async (messages) => string 协议的客户端 |
ctx |
object | 必填;服务端构造的用户上下文 { userId, roles };缺失直接拒绝(fail-secure),且上下文绝不进入任何 LLM 消息 |
maxRetries |
number(默认 3) |
失败后的最大重试次数(总尝试 ≤ 1 + maxRetries) |
knowledge |
string | 覆盖 system prompt 知识文本(缺省用包内 ask_knowledge.md) |
返回 AskResult { data, attempts, events }。每次尝试为
{ llmRaw, gql, params, rows }——成功轮不含任何 error 键;失败轮携带
{ error: { code, message } },code ∈ badLlmOutput、profileBlocked、
permissionDenied、planError。LLM 客户端自身故障(LlmError:网络 /
HTTP 状态 / 空 content)原样穿透——链路故障显式失败,不重试。
const { llm } = require('nodejs-store');
llm.registerLlm('deepseek', llm.makeOpenaiCompat({
baseUrl: 'https://api.deepseek.com/v1',
model: 'deepseek-chat',
apiKey: process.env.DEEPSEEK_API_KEY,
}));
const result = await store.ask('我最近 10 笔订单的金额合计是多少?', {
llm: 'deepseek',
ctx: { userId: 'u1', roles: ['viewer'] },
});
console.log(result.data); // 查询结果行
console.log(result.attempts.at(-1).gql); // 模型产出的 GQL
text2query档位是进程级 core 单例:同一进程内并发调用ask()会互相串扰。 请在进程内串行化问数,或每个任务独享进程/worker。
store.describeForAi(ctx?)
输出给 LLM prompt 用的权限过滤 schema 摘要——紧凑 JSON 数组,每模型一条
{ name, fields, relations, computes }。无上下文时仅暴露模型名与字段名
(不暴露类型细节,防探针)。有上下文时,模型/字段/关系/计算列按调用者角色过滤
(canRead / readableFields / readableRelations / readableComputes),
归档表(*Deleted)排除,运维细节(indexes / datasource / location)不进 prompt。
const summary = store.describeForAi({ userId: 'u1', roles: ['viewer'] });
// [{ name: 'Order', fields: { _id: 'string', code: 'string', ... }, relations: { ... }, computes: { ... } }]
原生绑定未导出 readableComputes 判决时,配置了 read 白名单的计算列会保守
排除出摘要,并 emit askSummaryComputeSkipped 反馈事件——宁缺勿泄。
llm 注册表
可插拔 LLM 客户端,不内置任何 SDK。客户端就是一个函数
async (messages: Array<{ role, content }>) => string:
const { llm } = require('nodejs-store');
llm.registerLlm('deepseek', client); // 同名重复注册 → 报错
llm.getLlm('deepseek'); // 未注册 → 报错
llm.makeOpenaiCompat({ // OpenAI 兼容通用工厂(全局 fetch,零依赖),
baseUrl, model, apiKey, // 覆盖 DeepSeek / OpenAI / Moonshot / Ollama 等
jsonMode: true, // false → 不传 response_format(prompt 约定 + 严格解析回喂)
effort: 'low', // null → 不传 reasoning_effort
maxTokens: 4096,
timeoutMs: 90000,
});
客户端故障抛 LlmError(.detail 结构化,code ∈ llmNetworkError、
llmHttpError、llmEmptyContent、llmJsonPromptMissing);store.ask
对其原样穿透。
底层模块
该包会重导出其构建模块,供高级宿主使用:
const {
init, store, Store,
PermissionError, // 拒绝访问时抛出(status = 403)
PushdownUnsupportedError, // 命令无法安全下推时抛出
datasource, schema, permission, crud, executors, feedback, introspect,
syncSchema, // 与 store.syncSchema 是同一函数
} = require('nodejs-store');
// introspect.run(backend, driver, options) → 归一化结构行
const rows = await introspect.run('mysql', pool, {});
// executors.createConnection(kind, driver, options) → SQL 数据源描述符 { kind, exec }
await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) });
schema/permission/feedback/datasource暴露的是store单例所委托的同一批函数(例如datasource.setConnections、datasource.hasConnection、datasource.isSql、datasource.runInTransaction)。- 多租户路由覆盖 —— 把
{ source, database, schema }作为任意 查询/写入的最后一个参数传入,见 多数据源连接。
事务边界
| 场景 | 原子性 |
|---|---|
单命令 API(insert / insertMany / updateMany / upsert / remove / count / exists) |
单 SQL 源内天然原子(单条 SQL);Mongo 单文档原子 |
store.transaction(source, fn) |
单 SQL 源内原子:作用域内所有命令同连接、同事务;同源嵌套开保存点(内层失败只回滚本层) |
store.session(...) |
会话内单 SQL 源跨多次调用原子;跨源写被显式拦截(NonAtomicWriteError) |
| 无会话的跨源多写 | 非原子(无 2PC / Saga 支持),按数据源顺序执行,并经反馈通道声明 nonAtomic(事件 non_atomic_write,含涉及源) |
| Mongo 多步写 | replica set / sharded:单 Mongo 源原子(session 事务);standalone:非原子并显式声明 mongo_transaction_unsupported |
工作流 run(runWorkflow) |
单源 run 跨步骤整体原子(外层 runAtomic 包住步骤循环、内层 mutation 嵌套并入);多源 / 源预扫失败按顺序执行并经反馈通道声明非原子 |
触发链(schema triggers,insert / update 事件) |
与源写同一 runAtomic 包络:单源真事务(同事务回滚);触及第二数据源按顺序执行并声明 non_atomic_write;store.session() 内对跨源写 fail-closed |
- Mongo 源:会话内按运行时能力探测结果事务化;不可事务(standalone / 探测失败)按原样执行,
并发出
mongo_transaction_unsupported反馈(deployment: standalone|unknown)(允许降级,绝不静默假装已事务化); - 未实现
openTransaction的 SQL 执行器:会话内按原样执行,并发出session_not_atomic反馈(允许降级,绝不静默假装已事务化); - 未实现
withTransaction的 SQL 执行器:store.transaction/ 顶层原子包络内按原样执行, 并发出transaction_not_atomic反馈(与session_not_atomic对称,允许降级,绝不静默假装已事务化); - 归档幂等:
remove的归档采用按_idupsert 的语义,因此部分失败后的重试不会再因_id重复而失败。 - 读一致性:只有在显式会话内的多条读才共享同一事务连接;会话外读不额外开启事务。
- 跨源写(非会话):一次写调用涉及 ≥2 个数据源时无法原子,按顺序执行,并发出一条
non_atomic_write反馈(code: nonAtomic,含涉及源列表)——允许降级、禁止静默。 把写收敛到单源,或放入store.session()内(后者对跨源写直接 fail-closed)。
触发器
schema 声明式触发链:写事件(insert / update / remove)由 Rust 核心在规划期展开为有序副作用步骤(plan.triggers),宿主在源写同一原子包络内依次执行。声明形态(triggers 三类键 + T 字段表)与占位符语法以 rust-store README「触发器」章节为准;core 侧展开输出跨端逐字节一致(rust-store/fixtures/triggers/cases.json golden 守护)。
回调装配(src/crud/triggers.js):
store.setTriggerFn('grantPoints', async (args, ctx, { store }) => {
await store.update('User', { _id: args.userId }, { $inc: { points: args.amount } });
});
store.assertTriggerFnsCovered(defns); // 启动期:schema 声明的 fnRef 缺实现 ⇒ ERR_TRIGGER_FN_MISSING
执行语义:
- 命中判定 —— update 事件先判
onFields 值真的变化(结构深比较,no-op 抑制——值未变不触发)→ 再判when(eq/ne/gt/gte/lt/lte/in/and/or/not);insert 事件无 before,跳过字段级检查;remove以归档(删除前)文档为根。系统字段(createdAt/updatedAt/deletedAt)不进触发器探针投影,其变化天然不触发。 - 占位符 ——
{{root.*}}(变化后值)/{{before.*}}(变化前值)/{{now}}只做整值替换;内嵌拼接显式报ERR_TRIGGER_PLACEHOLDER,禁静默漂移。 - 去重 —— 同一次顶层调用内
(step.name, _id)只执行一次。 - 回调式 ——
impl(args, ctx, { store });回调内store.*落到当前事务连接 → 与主写同事务。 - 定时(cron) ——
schedule事件由宿主定时任务插件(src/scheduler/index.js)枚举并复用同一触发链;占位符仅允许{{now}}。
边界:单源真事务 / 跨源 nonAtomic 声明 / store.session() 内跨源 fail-closed(见事务边界);不支持级联(触发写不再触发任何触发器);updateMany 不支持字段级触发(显式拒绝,禁静默降级);命令式步骤 op: "upsert" 注册期 Err。
事务型能力
面向事务型业务场景(订单、库存——写竞争 + 复杂读)的能力增补。完整语义、用法与显式报错清单: doc/transaction-capabilities.zh-CN.md · English.
- mutation 关系谓词 ——
updateMany('Inventory', { product: { category: 'meat' } }, { $inc: { stock: 10 } }):条件键命中已声明关系即 semi/anti-join,归一为 preCommand(aggregate 取_id)+_id $in。 $group byone 关系路径 ——by: ['product.category']编译为$lookup+$unwind(Mongo)/LEFT JOIN(SQL);many 路径显式报错(扇出破坏计数语义)。- 自增主键 ——
_id: { type: 'int', strategy: 'autoincrement' };PG/SQLite 经INSERT…RETURNING回读、MySQL 经 insertId;MongoDB 与insertMany显式报AUTOINCREMENT_NOT_SUPPORTED(禁 ObjectId 静默顶替)。 - 索引 DDL ——
schema.indexes(Mongo 形态)→ddl.generate产出CREATE [UNIQUE] INDEX idx_<表>_<字段>,MySQL/PostgreSQL/SQLite 三方言逐字节一致。 - 声明式迁移 ——
ddl.diffDefs(old, new)+ddl.generateMigration(backend, old, new):白名单制(加表/加列/加索引/类型放宽),纯函数按方言产 SQL;白名单外显式报MIGRATION_UNSUPPORTED。
工作流编排(首批)
把「多步数据操作的编排」用数据表达:工作流定义(Workflow defn)是与 schema defn 同构的纯 JSON、
运行记录(run)落库为内建 schema __workflowRun(普通 GQL 即查,可观测性零新接口)。执行是既有
mutation 步骤序列机制的推广——线性步骤 + 步骤级 when 守卫 + fail-fast。引擎落在宿主层
(src/workflow.js),core 零改动;对齐 py-store/src/py_store/workflow.py(双宿主输出逐字节
一致由 parity 锚单测守护)。
store.registerWorkflow({ // 注册即静态校验;白名单外显式 Err(WORKFLOW_UNSUPPORTED)
name: 'placeOrder',
run: ['admin', 'ops'], // 三级白名单 read/write/run(run 缺省回退 write)
steps: [
{ op: 'query', as: 'inv',
gql: 'Inventory($condition:@c0){_id, stock}',
params: { c0: { productId: '{{input.productId}}', warehouse: '{{input.warehouse}}' } } },
{ op: 'fail', when: { exists: '{{inv._id}}', is: null }, message: '库存记录不存在' },
{ op: 'fail', when: { lt: '{{inv.stock}}', than: '{{input.qty}}' }, message: '库存不足' },
{ op: 'mutation', model: 'Inventory',
data: { _id: '{{inv._id}}', stock: '{{dec:{{inv.stock}},{{input.qty}}}}' } },
],
});
const run = await store.runWorkflow('placeOrder', { productId: 'p1', warehouse: 'w1', qty: 30 });
// run.status ∈ succeeded | failed | rejected | drySucceeded | dryFailed
// 统一契约:业务失败不抛错,错误在 run.error(失败才有值;succeeded 态恒为 null)
- 步骤白名单(首批仅三种,白名单外注册即
WORKFLOW_UNSUPPORTED):query(store.query; 结果单条化,>1 行显式 Err)、mutation(store.mutation / upsert,继承其步骤序列与占位符机制)、fail(显式业务断言失败:run 记 failed + stepIndex + message)。步骤可选when守卫 (exists / is / eq / ne / lt / lte / gt / gte),不满足记skipped——显式留痕,绝不静默跳过。 - 占位符:
{{input.<path>}}(本次 run 输入)、{{<as>.<path>}}(前序步骤结果,必须前向引用)、{{dec:<a>,<b>}}(递减);整值替换保类型、内嵌替换字符串化。gql 内禁占位符(参数走 params 绑定,防注入);数组下标路径不支持(逐行处理请走宿主代码编排)。 - 权限:defn 内嵌三级角色白名单(复用四级 RBAC 语义:admin / super_admin 放行、guest 拒绝、
internal 放行、creator 按 Missing 通过);run 继承触发者 Context,每步 query/mutation 都过 core
权限判定——工作流是「权限内的一次次普通调用」,不存在超级身份。
requireContext(true)开启时 无 ctx 拒跑(fail-secure 优先于 dry-run)。rejected 同样落库(拒绝可审计)。 - 原子性:单源 run 整体原子(外层
runAtomic包住整个步骤循环,内层 mutation 嵌套并入—— 任一步失败整体回滚);多源 / 预扫失败按顺序执行并发反馈事件(workflow_non_atomic/workflow_prescan_failed,禁静默)。run 记录时序:先落running(进程崩溃可见)→ 步骤事务 → 终态在事务外独立提交(业务回滚不影响失败 run 可查)。 - dry-run:
store.runWorkflow(name, input, { dryRun: true })——query 真实执行(只读安全), mutation / fail 记wouldRun;终态drySucceeded | dryFailed。 - run 落库:
__workflowRun由模块加载即自举注册(幂等);SQL 后端首次启用工作流需执行ddl.generate(backend, ['__workflowRun'])建表(Mongo 无需,首次写入自动建集合)。 其write为显式空名单(普通角色 GQL 篡改 run 审计被 R2 拒绝;模块内部写入走 internal 上下文)。
首批明确不做(检出即 Err,边界与能力同权重)
| 不做 | 理由 | 出口 |
|---|---|---|
| 循环 / 并行 / 子工作流 / 人工审批 | DAG 与人工等待语义复杂度爆炸;线性 + when 覆盖首批场景 |
宿主代码用 store API 编排 |
| 自动补偿(Saga)/ 自动重试 | 反向操作语义负担大;步骤无自动幂等保证 | 人查 run 记录显式处置 |
| 步骤级宿主回调 | 任意代码击穿白名单治理 | schema computes(读)/ 宿主代码(写) |
| 工作流定义存库 / 热更 | 依赖 schema 版本化先行(路线图下一步) | defn 暂与 schema 同模式:代码内 JSON + register |
| 定时触发 / 事件触发 | 触发器是常驻 IO 职责,属调度层,与「纯编排」正交 | 应用层自行调用 runWorkflow |
| gql 内嵌占位符 / 数组下标路径 | 注入面 / 数组逐行处理语义 | params 绑定 / 宿主代码编排 |
场景事例
以下是一批可直接运行的场景,用来展示数据层在哪些地方真正创造价值 —— 每个场景都按 业务背景 → 代码 → 结果 → 优势所在 四段展开,并与手写方案(直接调驱动、手写 SQL、ORM)逐项对照。
1. 用 JSON 定义模型直接查询——无需 ORM 样板代码
业务背景。 你需要一个带默认值、派生标签和索引的 Post 模型。用 ORM 得写实体类、迁移和生成的客户端;这里只写一个 JSON 对象就能开始查询。
const { init, store, local } = require('nodejs-store');
await init({ default: local.connect({ dir: './data' }) }); // 也可换成任意 Mongo / SQL 连接
store.register({
name: 'Post',
collection: 'posts',
idPrefix: 'PT',
timestamps: true,
fields: {
title: { type: 'string', default: '' },
status: { type: 'string', default: 'draft' },
},
computes: {
statusLabel: { type: 'string', depends: ['status'], fn: (d) => d.status.toUpperCase() },
},
indexes: [{ keys: { status: 1, createdAt: -1 } }],
});
await store.insert('Post', { title: 'Hello' });
const items = await store.query(
'Post($condition:@c0,$sort:@s1,$limit:@l1){ title, status, statusLabel }',
{ c0: { status: 'draft' }, s1: { createdAt: -1 }, l1: 20 },
);
结果。
// items = [{ title: 'Hello', status: 'draft', statusLabel: 'DRAFT' }]
存储的文档只保留用户数据(title、status 以及框架的 _id / createdAt / updatedAt);statusLabel 在读取时计算。
优势所在。 一个 JSON 对象取代了典型 ORM 的「实体类 + 迁移 + 生成客户端」,而且 title 这类字段不需要声明两次(列和 getter):默认值在读取时补全、计算列在读取时执行,因此写入永远不携带派生数据。又因为模型是数据而非代码,它可发布、可 diff、可热更新。
2. 换后端不动业务代码
业务背景。 你在开发环境用 MongoDB,但生产租户要求 PostgreSQL,而你不希望再维护第二套数据访问层。
const { init, store } = require('nodejs-store');
// 一份 schema,不含任何后端专属字段。
store.register({
name: 'Order',
collection: 'orders',
idPrefix: 'OD',
timestamps: true,
fields: {
title: { type: 'string', default: '' },
status: { type: 'string', default: 'draft' },
amount: { type: 'float', default: 0 },
},
computes: { total: { type: 'float', depends: ['amount'], fn: (d) => d.amount * 1.1 } },
indexes: [{ keys: { status: 1, createdAt: -1 } }],
});
const gql = 'Order($condition:@c0,$sort:@s0){ _id, title, status, total }';
const params = { c0: { status: 'open' }, s0: { createdAt: -1 } };
// 开发环境:MongoDB —— 一条聚合管道。
await init({ default: mongoDb });
const fromMongo = await store.query(gql, params);
// 生产环境:PostgreSQL —— 只换数据源,GQL 完全一致。
await init({ default: { kind: 'postgres', exec: pgPool } });
const fromPostgres = await store.query(gql, params);
结果。 两次调用返回相同的文档结构 [{ _id, title, status, total }];Mongo 读取是一条聚合管道($match / $sort),PostgreSQL 读取是一条参数化 SELECT ... ORDER BY。参见 doc/use-cases/03。
优势所在。 用 Prisma / TypeORM 你得维护两套客户端/实体;用原始驱动你得重写查询。在这里后端只是 init() 的入参 —— schema 不含后端专属字段,同一段 GQL 字符串会被规划并执行到任一后端上。你可以在灰度切换期间让两个数据库并行运行,而 store.syncSchema({ backend, driver, overlay }) 能把既有 SQL 结构读进同一注册表,让你的权限/计算列叠加其上。
3. 一次 GQL 读取替代 N+1 循环
业务背景。 某接口列出一页订单,并为每个订单带出它的明细项。朴素写法是循环查询。
// N+1:列表 1 次查询 + 每个父记录 1 次查询
const orders = await store.query(
'Order($condition:@c0,$sort:@s0,$limit:@l){ _id, code }',
{ c0: { status: 'open' }, s0: { createdAt: -1 }, l: 20 },
);
for (const order of orders) {
order.items = await store.query(
'OrderItem($condition:@c0,$sort:@s0,$limit:@l){ sku, qty }',
{ c0: { orderId: order._id }, s0: { qty: -1 }, l: 5 },
); // 20 个父记录 → 21 次往返
}
在 schema 里声明一次关系,然后在同一次读取里索取它:
store.register({
name: 'Order',
collection: 'orders',
timestamps: true,
fields: { code: { type: 'string', default: '' }, status: { type: 'string', default: 'draft' } },
relations: { items: { model: 'OrderItem', type: 'many', localField: '_id', foreignField: 'orderId' } },
});
store.register({
name: 'OrderItem',
collection: 'order_items',
fields: { orderId: { type: 'string' }, sku: { type: 'string', default: '' }, qty: { type: 'int', default: 1 } },
});
const orders = await store.query(
'Order($condition:@c0,$sort:@s0){ _id, code, items($sort:@s1,$limit:@l1){ sku, qty } }',
{ c0: { status: 'open' }, s0: { createdAt: -1 }, s1: { qty: -1 }, l1: 5 },
);
结果。 一次调用返回嵌套文档:
// orders = [{ _id, code, items: [{ sku, qty }, ...] }, ...]
在 MongoDB 上这是一次 aggregate()($lookup,并带每个父记录各自的 $sort/$limit);在 SQL 上这是一次 SELECT 配合 LEFT JOIN,施加 ROW_NUMBER() OVER (PARTITION BY "orderId" ORDER BY "qty" DESC) <= 5。
优势所在。 子查询只声明一次(relations),并作为父读取的一部分被解析,因此它不会与父查询脱节,也没有需要手写的东西。两层共享相同的 $condition/$sort/$skip/$limit 语义,权限/计算列按层解析。执行次数是固定的,与分页大小无关 —— 没有 N+1。(根的 $skip/$limit 与关系同时出现时会使用有界的两阶段形态 —— 两次执行,仍不是 N+1。)
4. 行级访问:不同角色看到不同数据
业务背景。 编辑只能查看和修改自己创建的课程;管理员可以看全部;访客永远不能写。
const { store, PermissionError } = require('nodejs-store');
store.register({
name: 'Course',
collection: 'courses',
fields: {
title: { type: 'string', default: '' },
status: { type: 'string', default: 'draft' },
createdBy: { type: 'string' },
},
read: ['admin', 'editor', 'creator'],
write: ['admin', 'editor', 'creator'],
});
// 每个请求一次(中间件层)。
store.setContext({ userId: req.user.id, roles: req.user.roles });
// 角色为 editor、userId 为 'u1' 时:
const mine = await store.query('Course($condition:@c0){ _id, title, status }', { c0: {} });
// → 属主条件 { createdBy: 'u1' } 被自动注入
结果。 因为 schema 授予了 creator,查询会被注入一个 createdBy 条件,于是编辑只能看到自己的行;对不属于自己的行执行 update / remove 会抛 PermissionError(status = 403)。guest 的写入被直接拒绝,admin / super_admin 则绕过白名单。
优势所在。 行级属主是一句 schema 声明(read/write 列表里的 creator)加每请求一次上下文 —— 没有每个处理器都要记的 WHERE createdBy = ? 可写可漏,也没有每接口一段的 if (user.role) 阶梯。基于 AsyncLocalStorage 的上下文会贯穿嵌套调用,因此请求内发出的任何查询(哪怕来自某个辅助函数)都会获得同样的属主注入。被拒绝是一次带类型的 PermissionError(映射为 HTTP 403),而不是静默返回空结果。
5. 写一次、随处可读的计算列
业务背景。 界面需要课程的课时数和加价后的收入,但你不想存储派生值,也不想在每个接口里重复计算。
store.register({
name: 'Course',
collection: 'courses',
fields: { price: { type: 'float', default: 0 }, status: { type: 'string', default: 'draft' } },
relations: { lessons: { model: 'Lesson', type: 'many', localField: '_id', foreignField: 'courseId' } },
computes: {
revenue: { type: 'float', depends: ['price'], fn: (d) => d.price * 1.2 }, // 标量 fn
lessonCount: { type: 'int', agg: { $count: 'lessons' } }, // 关系 agg
},
});
const rows = await store.query('Course($condition:@c0){ _id, price, revenue, lessonCount }', { c0: {} });
结果。
// rows = [{ _id, price: 100, revenue: 120, lessonCount: 7 }, ...]
没有课时的课程 lessonCount 为 0;只有 price / status 被存储。
优势所在。 在 schema 里声明一次后,revenue / lessonCount 就能按名字从任意 GQL 中索取、跨接口复用 —— 无需每接口重算,也没有需要保持同步的反范式列。标量 fn 在读取时基于它的 depends 执行;关系 agg 在同一次查询内把关联行汇总起来,而不把父记录扇出。写入保持干净,因为派生值从不落库。实现按规范化后的 fnRef 绑定,因此宿主函数与 schema 无需手写名字即可一致(见模式参考中的 fnRef 说明)。
6. 用聚合替代手写 SQL 报表
业务背景。 一个仪表盘需要「按状态统计课程数量与价格总和,只保留课程数大于 1 的状态,按总和从高到低」。
const rows = await store.query(
'Course($condition:@c0,$group:@g0,$having:@h0,$sort:@s0,$limit:@l0){ status, n, total }',
{
c0: { status: { $ne: 'deleted' } },
g0: { by: ['status'], agg: { n: { $count: '*' }, total: { $sum: 'price' } } },
h0: { n: { $gt: 1 } },
s0: { total: -1 },
l0: 20,
},
);
结果。
// rows = [{ status: 'published', n: 12, total: 4800 }, ...]
优势所在。 归一化聚合内嵌在 GQL 中 —— 日常查询不写原生管道(原生聚合仅作 opt-in 逃生舱:store.aggregate() / executeNative() / executeRaw())、没有按方言写的报表 SQL。同一条查询被下推到全部五个后端(MongoDB 聚合阶段 / SQL GROUP BY ... HAVING),执行顺序固定($condition → $group → $having → $sort → $skip/$limit → 投影),算子集受白名单约束($count / $sum / $avg / $min / $max)。若要按关系的聚合量过滤父记录(「订单数大于 3 的商品」),使用关系聚合谓词 —— 一种 semi/anti-join,在 SQL 上编译为 EXISTS/NOT EXISTS,在 MongoDB 上编译为 $lookup+$match,且不扇出:
await store.query('Product($condition:@c0){ _id, name }', {
c0: { $and: [{ status: 'onSale' }, { orders: { $count: { $gt: 3 } } }] },
});
7. 自然语言问数——store.ask + describeForAi
业务背景。 客服想直接问「我最近 10 笔订单的总金额是多少?」而不借助查询构造器。
const { llm, store } = require('nodejs-store');
llm.registerLlm('deepseek', llm.makeOpenaiCompat({
baseUrl: 'https://api.deepseek.com/v1',
model: 'deepseek-chat',
apiKey: process.env.DEEPSEEK_API_KEY,
}));
const result = await store.ask('Total amount of my last 10 orders?', {
llm: 'deepseek',
ctx: { userId: 'u1', roles: ['viewer'] }, // 服务端上下文,必填
});
console.log(result.data); // 查询结果行
console.log(result.attempts.at(-1).gql); // 模型产出的 GQL
结果。 result 是一个 AskResult { data, attempts, events };data 持有结果行,attempts.at(-1) 持有 { llmRaw, gql, params, rows }。如果模型在 maxRetries 轮内无法产出合法且被允许的查询,store.ask 会抛 AskExhausted(携带 .attempts / .events),而不是返回空数据。
优势所在。 describeForAi(ctx) 喂给模型的是经过权限过滤的 schema 摘要 —— viewer 永远看不到它无权读取的模型/字段,归档表被剔除,运维细节(索引 / 数据源 / 位置)从不进入 prompt。护栏(只读的 text2query 档位、上下文强制、行/深度上限、关闭路由覆盖)硬编码在服务端,模型无法触达 —— LLM 输出被当作不可信输入。失败的轮次会以结构化方式回馈并重试,因此一次坏生成绝不会被静默变成空结果集。可与「每个接口手写一个 prompt、一个校验器和一个只读 SQL 守卫」对照。
8. 用 local 磁盘源做隔离测试
业务背景。 你想在单元测试里用到完整的 GQL / 关系 / 聚合语义,但不启动任何数据库。
const { init, local, store } = require('nodejs-store');
await init({ default: local.connect({ dir: './.tmp/store-test' }) });
store.register({ name: 'Post', collection: 'posts', fields: { title: { type: 'string', default: '' } } });
await store.insert('Post', { title: 'a' });
const rows = await store.query('Post{ _id, title }', {});
结果。 每个集合是 dir 下的一个 JSON 文件(例如 ./.tmp/store-test/posts.json);读取返回与任何后端相同的文档结构,同一段 GQL 原样运行 —— 无需服务器、无需原生数据库引擎。
优势所在。 local 源是五个后端中真实的一个,因此测试跑的是真正的核心语义(关系、$group、权限),无需准备 MongoDB/PostgreSQL,且同一份测试文件只需替换 init() 就能对真实后端运行。它的护栏是显式的:每个集合硬上限 100,000 个文档(绝不静默截断)、仅单进程、声明 indexes 会发出 local_indexes_ignored 反馈事件(它是顺序扫描存储)。
9. 把多步业务动作编排为工作流
业务背景。 「下单」是一个多步动作 —— 查库存、断言它存在且充足、然后扣减 —— 并且业务失败不能抛错。
store.registerWorkflow({
name: 'placeOrder',
run: ['admin', 'ops'],
steps: [
{ op: 'query', as: 'inv',
gql: 'Inventory($condition:@c0){ _id, stock }',
params: { c0: { productId: '{{input.productId}}', warehouse: '{{input.warehouse}}' } } },
{ op: 'fail', when: { exists: '{{inv._id}}', is: null }, message: '库存记录不存在' },
{ op: 'fail', when: { lt: '{{inv.stock}}', than: '{{input.qty}}' }, message: '库存不足' },
{ op: 'mutation', model: 'Inventory',
data: { _id: '{{inv._id}}', stock: '{{dec:{{inv.stock}},{{input.qty}}}}' } },
],
});
const run = await store.runWorkflow('placeOrder', { productId: 'p1', warehouse: 'w1', qty: 30 });
结果。
// run = { _id, workflow: 'placeOrder', status: 'succeeded' | 'failed' | 'rejected', steps, error, stepIndex, ... }
业务失败表达为 run.status === 'failed' 并带有可读的 run.error 与出错的 stepIndex —— 它不会被抛出。在单一数据源上整个步骤循环是原子的,run 记录被持久化到内建的 __workflowRun schema,可用普通 GQL 查询。
优势所在。 多步动作是纯 JSON,与 schema 定义同构 —— 可发布、可版本化、可审计,而不是一段冗长的过程式处理器。占位符({{input.*}}、{{<as>.*}}、{{dec:a,b}})把注入挡在 gql 之外(参数绑定而非字符串拼接),when 守卫显式记录 skipped(绝不静默跳过),三级 read/write/run 白名单复用同一套 RBAC 语义。store.runWorkflow(name, input, { dryRun: true }) 会真实执行查询,但把 mutation / fail 记为 wouldRun。刻意排除在范围外的能力(循环、人工审批、自动重试)会在注册时以 WORKFLOW_UNSUPPORTED 失败,而不是静默降级。
10. 在请求层统一 fail-secure
业务背景。 一个内部 API 绝不能服务一个忘了设置用户上下文的请求 —— 缺失上下文应当失败,而不是静默通过所有权限检查。
store.setRequireContext(true);
// 现在每个未设置上下文的查询/写入都会抛错:
await store.query('Post{ _id, title }', {});
// → 抛 `ERR_NO_CONTEXT:...`
// 受信的内部任务显式声明自己:
await store.runAsInternal(() => store.remove('Post', { _id: pid }));
要一次性翻转所有安全开关,使用统一入口:
store.secureMode({ adminRoles: ['internal'] });
// 同时开启:要求上下文 + 未配置白名单关闭 + 定义注册门闸
结果。 无上下文的请求快速失败并抛 ERR_NO_CONTEXT:...;runAsInternal 把调用标记为 { internal: true }(在语义上区别于上下文缺失),并且总是通过。store.secureMode() 返回 { secure: true };store.relaxMode() 恢复默认的 fail-open(仅供本地脚本/测试)。
优势所在。 「没有上下文」有两种含义 —— 系统调用和忘了设置上下文的调用方。默认 fail-open 会把后者静默当作前者并放行所有检查;一句 setRequireContext(true)(或 secureMode)把这一区分显式化并变为 fail-closed。没有每个处理器都要记的守卫,因为强制逻辑就在数据层、在每个查询/写入都要经过的唯一卡点上 —— 而内部任务仍可通过显式的 runAsInternal 完成。
11. 同一个 store 同时挂 MongoDB + MySQL —— 跨源关系
业务背景。 用户数据在 MongoDB,他们的订单在 MySQL(不同团队、不同生命周期)。接口需要一次调用拿到「用户连同他的订单」。
const { init, store, executors } = require('nodejs-store');
store.register({
name: 'User', collection: 'users', datasource: 'mongo_main', timestamps: true,
fields: { name: { type: 'string', default: '' } },
relations: { orders: { model: 'Order', type: 'many', localField: '_id', foreignField: 'userId' } },
});
store.register({
name: 'Order', collection: 'orders', datasource: 'mysql_main', timestamps: true,
fields: {
userId: { type: 'string' },
code: { type: 'string', default: '' },
amount: { type: 'float', default: 0 },
},
});
await init({
mongo_main: mongoDb, // MongoClient().db() 实例
mysql_main: executors.createConnection('mysql', pool), // mysql2/promise 连接池
});
const rows = await store.queryFederated(
'User($condition:@c0){ _id, name, orders{ code, amount } }',
{ c0: {} },
);
结果。 一次调用返回拼接后的形状:
// rows = [{ _id, name, orders: [{ code, amount }, ...] }, ...]
每个数据源各自用原生查询取数(Mongo 聚合 / MySQL SELECT),再在内存中 join;SQL↔SQL 的跨库关系则原生下推("db_a"."t" JOIN "db_b"."t")。
优势所在。 跨源关系与同源关系的声明方式完全一致 —— 手写方案的替代品是「两个驱动 + 手工内存 join」(以及它往往会长出的 N+1)。边界同样是显式声明的:不开会话的跨源写入不可能原子,库会顺序执行并发 non_atomic_write 反馈事件,绝不假装原子(见事务边界)。
12. 转账:事务与原生 SQL 同作用域
业务背景。 从一个账户扣款、向另一个账户入账。两者要么都提交、要么都不提交,且扣款读取必须锁行。
const { init, store, executors } = require('nodejs-store');
await init({ default: executors.createConnection('mysql', pool) });
await store.transaction('default', async () => {
const { rows } = await store.executeRaw(
'default', 'SELECT balance FROM account WHERE _id = :id FOR UPDATE', { id: 'acc_a' });
if (!rows.length || rows[0].balance < 100) throw new Error('insufficient funds');
await store.executeRaw(
'default', 'UPDATE account SET balance = balance - :amt WHERE _id = :id', { amt: 100, id: 'acc_a' });
await store.executeRaw(
'default', 'UPDATE account SET balance = balance + :amt WHERE _id = :id', { amt: 100, id: 'acc_b' });
});
结果。 三条语句全部落在同一条事务连接上 —— 正常退出即提交,抛出业务错误即回滚。命名参数 :name 会被编译为各方言的原生占位符(? / $1..$n),同名复用也没问题,SQL 字符串无需手工拼接。
优势所在。 原生 SQL 的逃生舱不会把你踢出事务:transaction(source, fn) 内的 executeRaw 与 CRUD 调用共用同一条连接,FOR UPDATE 锁可以和 schema 驱动的写入自由混用。降级必须声明、绝不假装:Mongo standalone 发 mongo_transaction_unsupported,未实现 withTransaction 的执行器发 transaction_not_atomic,同源嵌套作用域走保存点(内层失败只回滚本层)。用裸驱动的话,你得手工把连接对象在每个辅助函数间传来传去。
13. 会话(Session / 工作单元):多次写入、一次收口
业务背景。 「下单」要写三处 —— 订单、库存扣减、台账计数。它们构成一个工作单元;部分成功就是 bug。
await store.session(async (s) => {
await s.insert('Order', { code: 'A1', userId: 'u1', amount: 100 });
await s.update('Inventory', { productId: 'p1', warehouse: 'w1' }, { $inc: { stock: -1 } });
await s.executeRaw('default', 'UPDATE ledger SET ops = ops + 1', null, true);
});
// 上面任何一处抛错 → 整个会话回滚;绝不提交半截
结果。 回调内的每条命令都落到该数据源的同一条事务连接上,退出时统一提交一次。写了 ≥2 个数据源的会话 fail-closed —— 全部回滚并在退出时抛 NonAtomicWriteError(没有分布式事务,就不提交半截的工作单元)。
优势所在。 工作单元是环境式的:回调内调用的辅助函数自动加入同一事务,无需把连接对象层层传递(与权限层用的是同一套上下文机制)。惰性开事务意味着没有任何命令的会话不占用连接;嵌套会话开保存点,内层失败只回滚内层、外层可继续。手写版本是「记得把 conn 传进每个函数」—— 忘掉其中一个辅助函数的版本,就是部分写入上线的方式。
14. 从 schema 生成 DDL:交给迁移工具的起点
业务背景。 模型已经用 JSON 定义好;现在想要与之匹配的 CREATE TABLE 文本,交给你的迁移工具 —— 或者在测试容器里建出同样的表 —— 又不想给库任何写库权限。
store.register({
name: 'Course', collection: 'courses', idPrefix: 'c_', timestamps: true,
fields: {
_id: { type: 'string' },
title: { type: 'string', default: '' },
status: { type: 'string', default: 'draft' },
price: { type: 'float', default: 0 },
},
indexes: [{ keys: { status: 1, createdAt: -1 } }],
});
const text = store.generateDdl('sqlite'); // 所有已注册模型
const mysql = store.generateDdl('mysql', ['Course']); // 只要一张表
结果。 纯文本,一次调用一种方言。SQLite 的输出:
CREATE TABLE "courses" (
"_id" TEXT PRIMARY KEY,
"title" TEXT,
"status" TEXT,
"price" REAL,
"created_at" INTEGER,
"updated_at" INTEGER,
"__present" TEXT
);
CREATE INDEX "idx_courses_status_created_at" ON "courses" ("status" ASC, "created_at" DESC)
优势所在。 generateDdl(backend, names) 是 syncSchema() 的逆操作:一个已注册 def → 一条 CREATE TABLE(已声明索引附 CREATE INDEX),可渲染 mysql / postgres / sqlite 三种方言。它绝不连接、绝不写入数据库 —— 文本归你审阅、版本化并交给自选的迁移工具执行。渲染 SQL 需要显式声明 _id(若由库生成 id 还需 idPrefix);<Model>Deleted 归档表与其它已注册 def 一样生成。
15. 声明式触发器做审计
业务背景。 Order 的每次状态变更、每次删除都要留审计 —— 且与写入本身同事务,没有每个处理器都要记得调用的审计函数。
store.register({
name: 'Order', collection: 'orders', idPrefix: 'o_', timestamps: false,
fields: { amount: { type: 'number' }, status: { type: 'string', default: 'draft' } },
triggers: {
update: [
{ name: 'audit_status', onFields: ['status'], into: 'AuditLog', op: 'insert',
data: { _id: '{{now}}', kind: 'status', ref: '{{root._id}}',
prev: '{{before.status}}', next: '{{root.status}}' } },
],
remove: [
{ name: 'audit_remove', into: 'AuditLog', op: 'insert',
data: { _id: '{{now}}', kind: 'remove', ref: '{{before._id}}' } },
],
},
});
store.register({
name: 'AuditLog', collection: 'audit_log', idPrefix: 'a_', timestamps: false,
fields: { kind: { type: 'string' }, ref: { type: 'string' },
prev: { type: 'string' }, next: { type: 'string' } },
});
结果。 store.update('Order', { _id }, { status: 'paid' }) 会把写入与触发器步骤一起规划,宿主在同一原子包络内执行:插入一条 AuditLog 行 { kind: 'status', ref: <orderId>, prev: 'draft', next: 'paid' } —— 若写入本身失败,则什么都不会插入。
优势所在。 审计是声明在模型旁边的数据:onFields 做结构深比较,值未变化就不触发;{{before.*}} / {{root.*}} / {{now}} 只做整值替换(把占位符嵌进字符串会抛 ERR_TRIGGER_PLACEHOLDER);触发器写入与源写入共享同一事务,「改了但没审计」不可能发生。命令式场景用回调触发器(fnRef + store.setTriggerFn,assertTriggerFnsCovered 在启动期校验缺实现即失败)。不级联,updateMany 显式拒绝字段级触发器 —— 边界都大声失败,绝不静默。
16. 软删除与可查询的归档
业务背景。 「删除」必须可撤销、可审计:行离开主表,但保持可查询 —— 供回收站接口和审计导出使用。
store.register({
name: 'Course', collection: 'courses', idPrefix: 'c_', timestamps: true,
fields: { title: { type: 'string', default: '' }, price: { type: 'float', default: 0 } },
});
const r = await store.remove('Course', { _id: 'c_001' });
// r = { deletedCount: 1, archivedCount: 1 } —— 行被移入 courses_deleted(附加 deletedAt)
// 归档表是一等 schema:普通 GQL,语义与任何模型相同。
const tombstones = await store.query(
'CourseDeleted($condition:@c0,$sort:@s0){ _id, title, price, deletedAt }',
{ c0: {}, s0: { deletedAt: -1 } },
);
结果。 remove() 先把命中的文档归档到自动置备的 <Model>Deleted 表/集合(附加 deletedAt),然后删除。之后重建相同 _id 也不会冲突 —— 归档写入按 _id upsert。
优势所在。 每个模型免费获得同一种审计友好的删除:不需要为每个模型设计「归档表 + 删除时复制的触发器」组合,也不存在忘记归档的代码路径。回收站就是对一个真实 schema 的一次 GQL 查询。空条件的 remove 会被直接拒绝,谁也不会误删整张表。
17. 最小 Web 服务:每请求一次设置身份
业务背景。 你想要一个小型 REST 风格服务,权限在数据层强制执行 —— 而不是在每个处理器里重复实现。
const express = require('express'); // 任意框架均可;这是最小示意
const { init, store, local } = require('nodejs-store');
await init({ default: local.connect({ dir: './data' }) });
const app = express();
app.use(express.json());
// 每个请求一次:数据层从这里读取身份。
// (认证中间件负责填充 req.user;游客保留显式的 guest 角色)
app.use((req, res, next) => {
store.setContext({ userId: req.user.id, roles: req.user.roles });
next();
});
app.get('/courses', async (req, res) => {
try {
const page = await store.queryWithCount(
'Course($condition:@c0,$sort:@s0){ _id, title, price }',
{ c0: {}, s0: { createdAt: -1 }, page: Number(req.query.page) || 0, pageSize: 20 });
res.json(page); // { items, total, hasMore, page, pageSize }
} catch (e) {
res.status(e.status || 500).json({ error: e.message }); // PermissionError 自带 status 403
}
});
app.listen(3000);
结果。 一个中间件设置 { userId, roles };任何处理器内(及其 await 的任何调用)的每次 store.* 都会按 schema 白名单做权限校验 —— guest 永不写、creator 的行按属主过滤、拒绝以带类型的错误返回而非空结果。
优势所在。 授权位于每个查询/写入都要经过的唯一卡点,因此「在处理器 X 里忘了校验」在结构上不可能发生 —— 处理器里根本不含权限代码。PermissionError.status = 403 直接映射为 HTTP 状态;queryWithCount 返回分页与总数并带硬性 pageSize 上限(5000);routeOverride —— 唯一能把请求重定向到其他租户的参数 —— 被明确文档化为受信服务端输入,绝不透传请求数据。Koa / Fastify 同样只是三行:中间件里设上下文、调用 store.*、把错误映射为响应。
性能
先给定位:nodejs-store 的性能模型是把工作推进数据库、在 Rust 里规划一次、只跨一次 FFI 边界。下方数字来自一台开发机 —— 请把它们当作数量级证据,而非认证基准。
本机实测(SQLite,内存库)
环境:2026-10-15,Node v25.1.0,Windows(win32),better-sqlite3 内存数据库(同步驱动,零网络),Rust 核心 rust-store-node 4.5.0(napi-rs)。方法:读取类用例先预热 1 次再计时 5 次取中位数;插入用例为单次冷跑(同批数据重复插入会撞 _id);每个用例计时前都先断言结果正确性。复跑脚本时两个 201 次查询的用例波动可达 ±25% —— 数量级与比值保持稳定。
| 操作 | 数据量 | 执行次数 | 耗时 | 对比基线 |
|---|---|---|---|---|
insertMany 批量插入(4 块 × 2,500 行) |
10,000 行 | 4 条语句 | ~253 ms(单次) | ≈ 39,500 行/秒 |
| 关系树读取 —— 1 条 GQL,200 父 × 10 子 | 2,000 子行 | 1 次查询 | ~56 ms(5 次中位) | 快 2.2× |
| 手写 N+1 循环(同一数据) | 2,000 子行 | 201 次查询 | ~122 ms(5 次中位) | 基线 |
$group + $sum 聚合(8 组) |
10,000 行 | 1 次查询 | ~5.3 ms(5 次中位) | 快 ~42× |
全量拉回 + JS reduce(同一数据) |
10,000 行 | 1 次查询 + JS 循环 | ~225 ms(5 次中位) | 基线 |
关系聚合计算列 $count(200 父) |
2,000 子行 | 1 次查询 | ~4.1 ms(5 次中位) | 快 ~9.9× |
逐父 store.count() 循环(同一数据) |
2,000 子行 | 201 次查询 | ~41 ms(5 次中位) | 基线 |
怎么读这组数字:
- 嵌入式同步驱动没有网络,所以 N+1 只贵 2–3×。换到真实网络(MongoDB / MySQL / PostgreSQL —— 每条查询一个网络往返)后,201 次往返对比 1 次会乘上你的 RTT:按每次往返 1 ms 计,就是约 200 ms 纯等待对比 0。结构性收益是固定的执行次数、与分页大小无关(见事例 3)。
$group的 ~42× 是模型的另一半:聚合被编译为一条原生GROUP BY ... HAVING,10,000 行数据从不跨越 FFI + JSON 还原边界;reduce 版本则要先把它们全部搬进 JS(见事例 6)。- 插入吞吐(约 4 万行/秒)包含单条 SQLite 连接上的
_id生成、时间戳与规划。SQLite 有绑定变量上限(约 32,767),10,000 行的insertMany必须分块(每次约 2,500 行)—— 这是 SQLite 的限制,不是本库的。
既有压测证据(四后端)
仓库自带四后端压测脚本(scripts/stress.js):MongoDB + MySQL + PostgreSQL + SQLite 挂在一条联邦关系上,8 workers × 200 轮 × 5 操作 = 8,000 次操作。其 2026-09-12 报告(doc/2026-09-12-压测报告.md)记录:0 错误、整体 637.7 QPS,跨 3 源联邦读取 p50 25 ms / p95 48 ms(项目开发机、LOCAL_CORE=1;该组数字属于那个环境,此处未复现)。
性能为何是设计出来的
- Rust 核心下推。 GQL 解析、权限校验、计算列规划与方言翻译都在 Rust(
rust-store-node,napi-rs):关系编译为每个后端一条原生查询($lookup/JOIN+ROW_NUMBER),聚合编译为GROUP BY/HAVING,关系谓词编译为EXISTS/NOT EXISTS—— 干活的是数据库,不是 JS。 - 规划一次、执行一次。 FFI 边界只为准入规划而跨(
store.buildPipeline()可在不执行的情况下暴露编译后的{ tokens, ast, pipeline, projection });此后宿主层只做驱动 IO。 - 有界的执行次数。 父+关系的读取是 1 条查询(根级分页时 2 条 —— 有界两阶段形态),绝不是 N+1;子级
$sort/$limit在同一条语句内施加。 - 读时计算列的代价是每返回行一次函数调用 —— 不是额外查询;
agg形态在同一条查询内把关系汇总上来。
局限,如实说
- SQLite 是同步的。
better-sqlite3会阻塞事件循环 —— 高并发热路径请选 MySQL / PostgreSQL / MongoDB(见什么时候不该用它)。 - 一台机器、一个后端。 上述全部数字来自本机、内存 SQLite、热缓存。MySQL / PostgreSQL / MongoDB 的服务端数字:待核实(无本地服务实测);SQL 侧的架构性论断(每次读取一条参数化语句)可自行用
store.buildPipeline()验证。 - 计算列随结果集大小扩展。 标量
fn对每个返回行执行一次 —— 若 profile 中显形,请少取行或少取计算列。 - 驱动层面没有魔法。 连接池大小、SQL 索引(
schema.indexes在 SQL 后端仅作元数据;只有 Mongo 源会在init()时自动建索引)与查询设计仍归你管。
常见问题
如何在 Node.js 中让一份 schema 同时用于 MongoDB 和 PostgreSQL?
把 schema 用 JSON 定义一次,用你的数据源调用 init(),然后对两者运行同一份 GQL。MongoDB 使用原生聚合;MySQL/PostgreSQL/SQLite 得到参数化 SQL。见快速开始。
如何在不写 $lookup 或 JOIN 的情况下查询嵌套/关系数据?
在 relations 中声明关系({ model, type: 'many' | 'one', localField, foreignField }),并在 GQL 选择集里引用关系名。它在 Mongo 上变成 $lookup,在 SQL 上变成 JOIN,以嵌套文档返回。
支持 GROUP BY / COUNT / SUM / AVG 吗?
支持 —— 归一化聚合是 GQL 的一部分:根级 $group / $having 与关系聚合谓词。见聚合。
能否按其子文档的聚合过滤父级(”订单数大于 3 的商品”)?
可以 —— 关系聚合谓词实现 semi/anti-join 而不扇出;SQL 使用 EXISTS/NOT EXISTS。
如何实现行级权限?
使用 store.setContext({ userId, roles }) 加上 schema 级的 read/write 白名单。creator 伪角色会自动加入属主校验与属主条件注入(归属键由 schema ownerKey 声明,缺省 createdBy)。guest 永不写。开启 setRequireContext(true) 可获得失败即安全的行为。
如何做软删除?
每个已注册的模型都会自动获得一个 <Model>Deleted 归档集合/表。store.remove() 先归档文档,再删除它;重新创建同一个 _id 不会冲突,因为归档写入是按 _id upsert。
能用于多租户应用吗?
可以。把 schema 定位到 (source, database, schema, collection),并按请求传入 { source, database, schema } 路由覆盖。仅把 routeOverride 当作受信的服务端输入。
它会执行迁移吗?
不会。syncSchema() 只通过 introspection 读取物理结构(introspect → 合并 overlay → 注册)。schema 变更 / DDL 是你所用迁移工具的职责。若想要一个起点,store.generateDdl(backend) 可从已注册 schema 渲染 CREATE TABLE 文本 —— 但它只是纯文本生成:绝不执行、也不写入 DDL。
能否在不运行的情况下查看生成的查询?
可以 —— store.buildPipeline(gql, params) 返回编译后的计划({ tokens, ast, pipeline, projection }),不执行,也不应用权限/计算列。
SQL 下推不可行时会发生什么?
命令会抛出 PushdownUnsupportedError,并且通过 setFeedbackSink 发出一个结构化反馈事件(sql_pushdown_unsupported)。跨源分页/排序的降级会发出 federation_degraded 事件。不会有任何静默失败。
它与 py-store 和 rust-store 是什么关系?
rust-store 是共享的 Rust 引擎(GQL 解析、权限、计算列、命令规划、SQL 方言翻译 —— 纯逻辑,无 IO)。nodejs-store(npm)与 py-store(pip storepy)是它前面的薄宿主:它们负责驱动 IO、回调与占位符替换。Node 与 Python 共享相同的 schema、相同的 GQL、相同的语义。
相关项目
py-store(Gitee)—— Python asyncio 孪生版(pipstorepy,from py_store import init, store)。rust-store(Gitee)—— 共享的 Rust 核心及其rust-store-node/rust-store-py绑定。text-to-query—— 配套技能:把自然语言问题转换为该数据层所需 GQL + params。
框架使用契约
- 定义层(数据):模型 / 权限 / 编排 / 接口 —— 一律纯 JSON,可发布、可回滚、可热更。
- 回调层(代码):数据源 IO、计算列实现、外部调用、事务 —— 以
fnRef声明、启动时注入;不可序列化,禁止入库。 - 可观测层:一切降级/拦截/兜底事件落
__feedback(可用 GQL 查询),绝不静默。