nodejs-store

nodejs-store

面向 MongoDB、MySQL、SQLite、PostgreSQL 与本地磁盘(local)的统一数据层 —— 用纯 JSON 定义模型,用 MongoDB 风格的 GQL 树语法查询,开箱即得基于角色的访问控制、计算列与软删除。

npm version license node backends query dialect

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 宿主,npm nodejs-store)· py-store(Python 宿主,pip storepy)


目录


它是什么

面向 Node.js 的轻量级、后端无关的数据层。你只需用纯 JSON 描述一次模型(fields、relations、computes、indexes、read/write 角色白名单)。库会据此推导出:

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:

典型的具体场景(完整演练见 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 归档表/集合

什么时候不该用它

明确边界能为你省下时间:

与同类方案的对比

以下为总体定位,并非基准测试 —— 请始终以各工具当前文档为准。

  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) ➖ ➖ ➖ ➖

与具体库的差异

仅为定位说明,基于撰写时这些项目的公开文档 —— 请以你自己的需求为准进行核实。

一句话:想要编译期类型与迁移就用 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' }) });

特性

GQL 语法

Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
  field1, field2, obj.subField,
  Relation($condition:@c2,$sort:@s3,$limit:@l2) { f3, Nested { f4 } }
}

从 4.x 升级到 5.0

破坏性变更:用户 $pipeline 透传已移除 —— 包含 $pipeline 的 GQL 现在在解析期显式失败(ERR_GQL_PARSE:),而不再被静默忽略,也不再按档位放行。

聚合

归一化聚合内置于 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,
  },
);

关系聚合谓词(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(不做关系处理)

注意:

事务与原生 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);

会话(Session / 工作单元)

await store.session(async (s) => {
  await s.insert('Order', { ... });
  await s.update('Account', cond, { ... });
  await s.executeRaw('pg_main', 'SELECT ... FOR UPDATE', [1]);
});

DDL 生成

let sql = store.generateDdl('mysql');                       // 所有已注册模型
sql     = store.generateDdl('postgres', ['Course', 'CourseDeleted']);

store.generateDdl(backend, names) 把一个已注册 schema def 映射为一条 CREATE TABLE —— 是 syncSchema()(只读取)的逆操作。生成器是纯文本:它绝不连接、也绝不写入数据库(铁律 6 依然成立)。

多数据源连接

每个 schema 由 (source, database, schema, collection) 定位 —— schema 仅适用于 PostgreSQL 源;该落点在 registry 内必须全局唯一(重复注册会抛错,而不是静默错路由)。

定义不携带落点字段: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:

// 多个 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 }));

失败即安全模式(可选开启)

“无上下文”既可能表示系统调用,也可能表示调用方忘记设置上下文 —— 默认情况下 后者会静默通过所有校验(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。

几条需要提前知道的边界规则(全部显式失败,绝不静默降级):

内联数组聚合

agg 路径首段命中 fields 声明的数组字段(而非关系)时,对文档自身的数组做聚合 —— 编译进同一条管道,不新增查询阶段:

算子 路径 空数组 / 缺字段
$size 数组字段本身 0
$sum 数组.元素字段 0
$avg / $max / $min 数组.元素字段 null
$countDistinct 数组.元素字段 0 —— 去重并剔除 null 后计数

规则(违规在注册期显式失败,绝不静默降级):

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).

向量检索

向量就是带维度与度量声明的普通字段值:你产出 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' } },
  ],
}

查询算子

const rows = await store.query(
  'Artwork($condition:@c){ _id, title, _score }',
  { c: { embedding: { $near: { $vector: [0.12, -0.03 /* ... */], $limit: 10 } } } },
);

降级语义

store.capabilities().vector 为静态能力报告:{ supported, native, backend, metrics, maxDim }。

迁移步骤

高级 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) });

事务边界

场景 原子性
单命令 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

触发器

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

执行语义:

边界:单源真事务 / 跨源 nonAtomic 声明 / store.session() 内跨源 fail-closed(见事务边界);不支持级联(触发写不再触发任何触发器);updateMany 不支持字段级触发(显式拒绝,禁静默降级);命令式步骤 op: "upsert" 注册期 Err。

事务型能力

面向事务型业务场景(订单、库存——写竞争 + 复杂读)的能力增补。完整语义、用法与显式报错清单: doc/transaction-capabilities.zh-CN.md · English.

工作流编排(首批)

把「多步数据操作的编排」用数据表达:工作流定义(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)

首批明确不做(检出即 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 次中位) 基线

怎么读这组数字:

既有压测证据(四后端)

仓库自带四后端压测脚本(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;该组数字属于那个环境,此处未复现)。

性能为何是设计出来的

局限,如实说

常见问题

如何在 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、相同的语义。

相关项目

框架使用契约

许可证

MIT