nodejs-store
v2.6.0
Published
Multi-backend data layer for Node.js (MongoDB, MySQL, SQLite, PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single native query, GROUP BY/HAVING aggregation, computed columns, soft-delete and role-based access control
Maintainers
Keywords
Readme
nodejs-store
面向 MongoDB、MySQL、SQLite 与 PostgreSQL 的统一数据层 —— 用纯 JSON 定义模型,用 MongoDB 风格的 GQL 树语法查询,开箱即得基于角色的访问控制、计算列与软删除。
English docs: README.md
nodejs-store 让 Node.js 服务通过单一 schema 定义、单一查询方言同时对接 MongoDB(原生聚合)、MySQL、PostgreSQL 与 SQLite。嵌套关系会编译为每个后端一条原生查询 —— 你永远不必手写 $lookup 或原生 SQL。
也在找 Python 版本?见
py-store(pip 包storepy)。两者都是共享 Rust 引擎rust-store之上的薄宿主。
目录
- 它是什么
- 什么时候该用它
- 什么时候不该用它
- 与同类方案的对比
- 安装
- 快速开始
- 支持的后端
- 特性
- 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, namespace, collection),并在执行时用{ source, namespace }覆盖把任意查询/写入重新定向到目标租户。
典型的具体场景(完整演练见 doc/use-cases/):
| 场景 | nodejs-store 为何合适 |
| --- | --- |
| 每租户独立 schema/数据库的多租户 SaaS | 每租户一个 namespace + 运行时路由覆盖,一套 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透传与store.aggregate()已被有意移除。请使用$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 | 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 |
GQL 树查询会编译为每个后端一条原生查询 —— 再也不必手写 $lookup 或原生 SQL。
特性
- 纯 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, namespace, 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 上翻译为窗口函数)。
破坏性变更:用户
$pipeline透传与store.aggregate()已移除(原生聚合逃生舱)。包含$pipeline的 GQL 现在会显式失败,而不再被静默忽略。
聚合
归一化聚合内置于 GQL —— 没有单独 API,没有原生管道。
根级 $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。 - 固定执行顺序:
$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)。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 一样生成。 - 不生成
CREATE INDEX—— SQL 后端仅把索引保留为元数据。 - MySQL 的
__present为VARCHAR(255);若某 schema 的 present 令牌串会超限,则发出ddlPresentOverflow反馈事件,而非静默失败。
多数据源连接
每个 schema 由三元组 (source, namespace, collection) 定位 —— 该三元组在 registry 内必须
全局唯一(重复注册会抛错,而不是静默错路由)。
source——init({...})中的连接键(默认"default")。namespace—— 连接内的数据库/schema:Mongo 库名、PG schema、 MySQL database、SQLite 附加库。可选;null= 连接默认。collection—— 表/集合名。
// 多个 Mongo 服务器:每个连接一个 source
await init({ mongo_main: db, pg_a: { kind: 'postgres', exec } });
// 同一个 MongoClient 服务多个数据库:声明 namespace(库名)
await init({ cluster: client });
store.register({ name: 'User', collection: 'users', datasource: 'cluster', namespace: 'tenant_42', ... });
// SQL 跨 namespace 的 join 会原生下推("ns_a"."t" JOIN "ns_b"."t");
// 只有 Mongo 的跨库关系会退回内存联邦。多租户路由覆盖 —— 一份 schema 定义,N 个租户。任意查询/写入都接受
一个 { source, namespace } 覆盖参数,在执行时把命令重新定向(权限
与计算列仍按结构 schema 判定):
await store.query('User($condition:@c0){...}', params, { namespace: 'tenant_42' });
await store.insert('Order', data, { source: 'pg_cluster', namespace: 'tenant_7' });routeOverride 是受信的服务端参数 —— 它不做来源校验,因此把用户可控输入
透传进来,会让调用者把命令重定向到其他租户的 source/namespace(CWE-639
授权绕过面)。绝不要把原始请求数据传到这里。
传统的单库用法(init(db) + 不含 datasource/namespace 的 schema)保持不变:
命令携带 source: 'default'、namespace: 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.createdBy === ctx.userId解析;授予它的 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: { ... } }, // 嵌套对象字段
},
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' } },
},
indexes: [
{ keys: { status: 1 } },
{ keys: { code: 1 }, options: { unique: true } },
],
read: ['editor', 'viewer'], // 可选的 schema 级角色白名单
write: ['editor'],
}类型:string | int | long | float | double | boolean | array | object | date | any。
几条需要提前知道的边界规则(全部显式失败,绝不静默降级):
- 直接对数组字段、整个对象字段或对象点路径做过滤,在任何后端都会被拒绝 —— 请改用
relations建模跨实体语义。 - 关系谓词仅支持一层关系;形如
orders.items.price的路径会被拒绝。 - 不可读的关系是错误,而不是静默的
false。
高级 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 |
| namespace | string | 把所有合并后的 def 绑定到该 namespace |
| 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 源重跑。
底层模块
该包会重导出其构建模块,供高级宿主使用:
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, namespace }作为任意 查询/写入的最后一个参数传入,见 多数据源连接。
事务边界
| 场景 | 原子性 |
|---|---|
| 单命令 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 |
- 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)。
事务型能力
面向事务型业务场景(订单、库存——写竞争 + 复杂读)的能力增补。完整语义、用法与显式报错清单: 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 三方言逐字节一致。
常见问题
如何在 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 伪角色会自动加入属主校验与属主条件注入。guest 永不写。开启 setRequireContext(true) 可获得失败即安全的行为。
如何做软删除?
每个已注册的模型都会自动获得一个 <Model>Deleted 归档集合/表。store.remove() 先归档文档,再删除它;重新创建同一个 _id 不会冲突,因为归档写入是按 _id upsert。
能用于多租户应用吗?
可以。把 schema 绑定到 (source, namespace, collection),并按请求传入 { source, namespace } 路由覆盖。仅把 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—— Python asyncio 孪生版(pipstorepy,from py_store import init, store)。rust-store—— 共享的 Rust 核心及其rust-store-node/rust-store-py绑定。text-to-query—— 配套技能:把自然语言问题转换为该数据层所需 GQL + params。
