nodejs-store 2.0.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,7 +98,7 @@ Typical concrete scenarios (see [`doc/use-cases/`](doc/use-cases/) for full walk
98
98
 
99
99
  Being explicit about the boundary saves you time:
100
100
 
101
- - **You want a full ORM with a migration engine.** `nodejs-store` is a *data layer*, not a migration tool. It can **read** a SQL backend's physical structure (`syncSchema` → introspection) but it never writes DDL back. Pair it with your migration tool of choice.
101
+ - **You want a full ORM with a migration engine.** `nodejs-store` is a *data layer*, not a migration tool. It can **read** a SQL backend's physical structure (`syncSchema` → introspection) but it never writes DDL back. Pair it with your migration tool of choice. There is an optional `generateDdl()` that renders `CREATE TABLE` text from your registered schemas — pure text, it never connects to or writes to the database.
102
102
  - **You need a type-safe generated client.** Schemas are runtime JSON, not TypeScript types. You get flexibility and cross-language parity (same schema runs in Node and Python), not compile-time type inference.
103
103
  - **You only ever use one database and rarely join.** A plain driver (or a single-database ODM/ORM) will be simpler.
104
104
  - **You need raw aggregation escape hatches.** `$pipeline` passthrough and `store.aggregate()` were deliberately removed. Use `$condition` / `$group` / `$having` / relations; anything that cannot be safely translated fails **explicitly** rather than silently.
@@ -117,7 +117,7 @@ General positioning, not a benchmark — always verify against each tool's curre
117
117
  | Built-in role / field-level RBAC + owner injection | ✅ | ➖ | ➖ (via extensions) | ➖ | ➖ |
118
118
  | Read-time computed columns (sync / async / relation-agg) | ✅ | ➖ (getters) | ➖ | ➖ | ➖ |
119
119
  | Soft-delete archive table auto-provisioned | ✅ | ➖ | ➖ | ➖ | ➖ |
120
- | Migration / DDL engine | ➖ (introspection read-only) | ➖ | ✅ | ✅ | ✅ |
120
+ | Migration / DDL engine | ➖ (introspection read-only; optional `generateDdl` text) | ➖ | ✅ | ✅ | ✅ |
121
121
  | Static type generation | ➖ (runtime JSON, cross-language parity) | ➖ | ✅ | ⚠️ (decorators + TS) | ✅ |
122
122
  | Shared native core across Node & Python | ✅ (Rust `rust-store`) | ➖ | ➖ | ➖ | ➖ |
123
123
 
@@ -128,7 +128,7 @@ Positioning only, based on those projects' public documentation at the time of w
128
128
  - **vs Mongoose** — Mongoose is MongoDB-only. `nodejs-store` uses a similar MongoDB-style query syntax (`$gt`, `$or`, `$set`, `$inc`) but the same query also runs unchanged against MySQL, SQLite and PostgreSQL.
129
129
  - **vs `mongoosql-core`** — the closest in spirit: it also runs Mongoose-style queries on MongoDB, PostgreSQL and MySQL. `nodejs-store` additionally targets SQLite, ships schema-level permissions (role/field whitelists plus `creator` owner-condition injection), read-time computed columns (`fn` / `asyncFn` / relation-`agg`), an auto-provisioned `<Model>Deleted` soft-delete archive, and shares one Rust engine with a Python host so Node.js and Python cannot drift apart.
130
130
  - **vs `unsql`** — `unsql` generates SQL from plain JavaScript objects for MySQL, PostgreSQL and SQLite. It does not target MongoDB, and it is a query/CRUD helper rather than a schema-driven data layer with permissions and computed columns.
131
- - **vs Prisma** — Prisma is a schema DSL plus generated client with a migration engine and compile-time types. `nodejs-store` is a runtime JSON schema with no DDL or migration responsibility (it only *reads* physical structure via introspection) and no type generation — in exchange for one query dialect spanning a document store and three relational stores.
131
+ - **vs Prisma** — Prisma is a schema DSL plus generated client with a migration engine and compile-time types. `nodejs-store` is a runtime JSON schema with no migration engine (it only *reads* physical structure via introspection, and `generateDdl()` only *renders* `CREATE TABLE` text without touching the database) and no type generation — in exchange for one query dialect spanning a document store and three relational stores.
132
132
  - **vs TypeORM / Sequelize / Drizzle** — Sequelize and Drizzle are SQL-only; TypeORM models MongoDB separately from its SQL entities. `nodejs-store` treats MongoDB as the primary dialect and compiles the same GQL to SQL for the other three backends.
133
133
 
134
134
  Short version: use an ORM when you want **compile-time types and migrations**; use `nodejs-store` when you want **one runtime schema + one query dialect spanning MongoDB and SQL**, with RBAC and computed columns built in.
@@ -302,6 +302,38 @@ Notes:
302
302
  - `queryWithCount` accepts `page`/`pageSize` (recommended) or the traditional `$skip`/`$limit` params.
303
303
  - `updateMany` / `remove` with an **empty condition** (`{}`, `null`, `{ "$and": [] }`) is rejected outright — it never falls through to a full-table write.
304
304
 
305
+ ### Transactions and raw SQL
306
+
307
+ ```js
308
+ async function transfer() {
309
+ const rows = await store.executeRaw(
310
+ 'default', 'SELECT * FROM accounts WHERE _id = ? FOR UPDATE', [accId]);
311
+ await store.executeRaw(
312
+ 'default', 'UPDATE accounts SET balance = ? WHERE _id = ?', [newBalance, accId],
313
+ true);
314
+ }
315
+
316
+ await store.transaction('default', transfer);
317
+ ```
318
+
319
+ - `store.transaction(source, fn)` opens a transaction scope on one SQL source: every `executeRaw` / CRUD call inside `fn` lands on that source's transaction connection, with `commit` / `rollback` as one unit (reuses the internal `runInTransaction`). Mongo sources or executors without transactions run `fn` as-is — it never pretends to be atomic.
320
+ - `store.executeRaw(source, sql, params, isWrite)` runs raw SQL, bypassing GQL parsing and dialect translation. Placeholders follow each backend's native style: `?` for MySQL / SQLite, `$1..$n` for PostgreSQL. SQL sources only — a Mongo source throws `RawSqlError` (`store.RawSqlError`).
321
+ - `isWrite=false` (default) returns `{ rows, affectedRows }` with the result-set rows; `isWrite=true` returns the affected-row count.
322
+
323
+ ### DDL generation
324
+
325
+ ```js
326
+ let sql = store.generateDdl('mysql'); // every registered model
327
+ sql = store.generateDdl('postgres', ['Course', 'CourseDeleted']);
328
+ ```
329
+
330
+ `store.generateDdl(backend, names)` maps one registered schema def to one `CREATE TABLE` — the inverse of `syncSchema()`, which only *reads*. The generator is **pure text**: it never connects to, or writes to, the database (iron rule 6 still holds).
331
+
332
+ - Only scalar fields become columns; `object` / `array` fields do not.
333
+ - Every table gets the `__present` sentinel column; `timestamps` models also get `createdAt` / `updatedAt`; the `<collection>_deleted` archive table is generated like any other registered def.
334
+ - No `CREATE INDEX` is emitted — SQL backends keep indexes as metadata only.
335
+ - MySQL `__present` is `VARCHAR(255)`; a schema whose present-token string would overflow emits a `ddlPresentOverflow` feedback event rather than failing silently.
336
+
305
337
  ## Multi-datasource connections
306
338
 
307
339
  Every schema is located by the triple `(source, namespace, collection)` — the triple must be
@@ -528,7 +560,7 @@ Every registered model automatically gets a `<Model>Deleted` archive collection/
528
560
  Yes. Bind a schema to `(source, namespace, collection)` and pass a `{ source, namespace }` route override per request. Treat `routeOverride` as trusted server-side input only.
529
561
 
530
562
  **Does it run migrations?**
531
- No. `syncSchema()` only *reads* physical structure via introspection (introspect → merge overlay → register). Schema changes / DDL are your migration tool's job.
563
+ No. `syncSchema()` only *reads* physical structure via introspection (introspect → merge overlay → register). Schema changes / DDL are your migration tool's job. If you want a starting point, `store.generateDdl(backend)` renders `CREATE TABLE` text from the registered schemas — but it is pure text generation: it never runs or writes DDL.
532
564
 
533
565
  **Can I see the generated query without running it?**
534
566
  Yes — `store.buildPipeline(gql, params)` returns the compiled plan (`{ tokens, ast, pipeline, projection }`) with no execution and no permission/compute application.
package/README.zh-CN.md CHANGED
@@ -97,7 +97,7 @@ MongoDB 是*主方言*:查询以 MongoDB 风格的 GQL 编写,三种关系
97
97
 
98
98
  明确边界能为你省下时间:
99
99
 
100
- - **你想要带迁移引擎的完整 ORM。** `nodejs-store` 是*数据层*,不是迁移工具。它可以**读取** SQL 后端的物理结构(`syncSchema` → introspection),但从不把 DDL 写回。请与你自选的迁移工具搭配使用。
100
+ - **你想要带迁移引擎的完整 ORM。** `nodejs-store` 是*数据层*,不是迁移工具。它可以**读取** SQL 后端的物理结构(`syncSchema` → introspection),但从不把 DDL 写回。请与你自选的迁移工具搭配使用。另有一个可选的 `generateDdl()`,可从已注册 schema 渲染出 `CREATE TABLE` 文本 —— 纯文本,绝不连接或写入数据库。
101
101
  - **你需要带类型安全、自动生成的客户端。** schema 是运行时 JSON,而非 TypeScript 类型。你获得的是灵活性与跨语言一致性(同一份 schema 在 Node 与 Python 中都可用),而不是编译期类型推断。
102
102
  - **你只用一种数据库,且几乎不做关联。** 直接用裸驱动(或只针对单一数据库的 ODM/ORM)会更简单。
103
103
  - **你需要原生聚合逃生舱。** `$pipeline` 透传与 `store.aggregate()` 已被有意移除。请使用 `$condition` / `$group` / `$having` / 关系;任何无法安全翻译的内容都会**显式**失败,而不会静默降级。
@@ -116,7 +116,7 @@ MongoDB 是*主方言*:查询以 MongoDB 风格的 GQL 编写,三种关系
116
116
  | 内置角色 / 字段级 RBAC + 属主注入 | ✅ | ➖ | ➖(通过扩展) | ➖ | ➖ |
117
117
  | 读时计算列(同步 / 异步 / 关系聚合) | ✅ | ➖(getter) | ➖ | ➖ | ➖ |
118
118
  | 自动置备软删除归档表 | ✅ | ➖ | ➖ | ➖ | ➖ |
119
- | 迁移 / DDL 引擎 | ➖(introspection 只读) | ➖ | ✅ | ✅ | ✅ |
119
+ | 迁移 / DDL 引擎 | ➖(introspection 只读;可选 `generateDdl` 文本) | ➖ | ✅ | ✅ | ✅ |
120
120
  | 静态类型生成 | ➖(运行时 JSON,跨语言一致) | ➖ | ✅ | ⚠️(装饰器 + TS) | ✅ |
121
121
  | Node 与 Python 共享原生核心 | ✅(Rust `rust-store`) | ➖ | ➖ | ➖ | ➖ |
122
122
 
@@ -127,7 +127,7 @@ MongoDB 是*主方言*:查询以 MongoDB 风格的 GQL 编写,三种关系
127
127
  - **vs Mongoose** —— Mongoose 仅支持 MongoDB。`nodejs-store` 使用类似的 MongoDB 风格查询语法(`$gt`、`$or`、`$set`、`$inc`),但同一条查询也能原样跑在 MySQL、SQLite 与 PostgreSQL 上。
128
128
  - **vs `mongoosql-core`** —— 精神上最接近:它同样能在 MongoDB、PostgreSQL 与 MySQL 上运行 Mongoose 风格的查询。`nodejs-store` 还额外面向 SQLite,内置 schema 级权限(角色/字段白名单,外加 `creator` 属主条件注入)、读时计算列(`fn` / `asyncFn` / 关系 `agg`)、自动置备的 `<Model>Deleted` 软删除归档,并与 Python 宿主共享同一个 Rust 引擎,因此 Node.js 与 Python 不会产生漂移。
129
129
  - **vs `unsql`** —— `unsql` 从普通 JavaScript 对象为 MySQL、PostgreSQL 与 SQLite 生成 SQL。它不面向 MongoDB,且只是一个查询/CRUD 辅助工具,而非带权限与计算列的 schema 驱动数据层。
130
- - **vs Prisma** —— Prisma 是 schema DSL 加生成的客户端,配有迁移引擎与编译期类型。`nodejs-store` 是运行时 JSON schema,不承担 DDL 或迁移职责(仅通过 introspection *读取*物理结构),也不生成类型 —— 以此换取一套横跨文档库与三种关系库的查询方言。
130
+ - **vs Prisma** —— Prisma 是 schema DSL 加生成的客户端,配有迁移引擎与编译期类型。`nodejs-store` 是运行时 JSON schema,不承担迁移引擎职责(仅通过 introspection *读取*物理结构,`generateDdl()` 也只*渲染* `CREATE TABLE` 文本而不触碰数据库),也不生成类型 —— 以此换取一套横跨文档库与三种关系库的查询方言。
131
131
  - **vs TypeORM / Sequelize / Drizzle** —— Sequelize 与 Drizzle 仅支持 SQL;TypeORM 把 MongoDB 与其 SQL 实体分开建模。`nodejs-store` 以 MongoDB 为主方言,并把同一份 GQL 编译为其余三种后端的 SQL。
132
132
 
133
133
  一句话:想要**编译期类型与迁移**就用 ORM;想要**一份运行时 schema + 一套横跨 MongoDB 与 SQL 的查询方言**,并内置 RBAC 与计算列,就用 `nodejs-store`。
@@ -301,6 +301,38 @@ await store.upsert('Post', { code: 'A1' }, { ... }); // 显式条件的 ups
301
301
  - `queryWithCount` 接受 `page`/`pageSize`(推荐)或传统的 `$skip`/`$limit` 参数。
302
302
  - 带**空条件**(`{}`、`null`、`{ "$and": [] }`)的 `updateMany` / `remove` 会被直接拒绝 —— 它绝不会退化为全表写入。
303
303
 
304
+ ### 事务与原生 SQL
305
+
306
+ ```js
307
+ async function transfer() {
308
+ const rows = await store.executeRaw(
309
+ 'default', 'SELECT * FROM accounts WHERE _id = ? FOR UPDATE', [accId]);
310
+ await store.executeRaw(
311
+ 'default', 'UPDATE accounts SET balance = ? WHERE _id = ?', [newBalance, accId],
312
+ true);
313
+ }
314
+
315
+ await store.transaction('default', transfer);
316
+ ```
317
+
318
+ - `store.transaction(source, fn)` 在单个 SQL 源上开启事务作用域:`fn` 内的每个 `executeRaw` / CRUD 调用都落到该源的事务连接,`commit` / `rollback` 作为一个整体(复用内部的 `runInTransaction`)。Mongo 源或不支持事务的执行器会按原样执行 `fn` —— 绝不假装已原子。
319
+ - `store.executeRaw(source, sql, params, isWrite)` 执行原生 SQL,绕开 GQL 解析与方言翻译。占位符沿用各后端原生风格:MySQL / SQLite 用 `?`,PostgreSQL 用 `$1..$n`。仅限 SQL 源 —— Mongo 源会抛出 `RawSqlError`(`store.RawSqlError`)。
320
+ - `isWrite=false`(默认)返回 `{ rows, affectedRows }` 含结果集行;`isWrite=true` 返回影响行数。
321
+
322
+ ### DDL 生成
323
+
324
+ ```js
325
+ let sql = store.generateDdl('mysql'); // 所有已注册模型
326
+ sql = store.generateDdl('postgres', ['Course', 'CourseDeleted']);
327
+ ```
328
+
329
+ `store.generateDdl(backend, names)` 把一个已注册 schema def 映射为一条 `CREATE TABLE` —— 是 `syncSchema()`(只*读取*)的逆操作。生成器是**纯文本**:它绝不连接、也绝不写入数据库(铁律 6 依然成立)。
330
+
331
+ - 只有标量字段成为列;`object` / `array` 字段不建列。
332
+ - 每张表都会获得 `__present` 哨兵列;`timestamps` 模型还会获得 `createdAt` / `updatedAt`;`<collection>_deleted` 归档表与其它已注册 def 一样生成。
333
+ - 不生成 `CREATE INDEX` —— SQL 后端仅把索引保留为元数据。
334
+ - MySQL 的 `__present` 为 `VARCHAR(255)`;若某 schema 的 present 令牌串会超限,则发出 `ddlPresentOverflow` 反馈事件,而非静默失败。
335
+
304
336
  ## 多数据源连接
305
337
 
306
338
  每个 schema 由三元组 `(source, namespace, collection)` 定位 —— 该三元组在 registry 内必须
@@ -525,7 +557,7 @@ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) }
525
557
  可以。把 schema 绑定到 `(source, namespace, collection)`,并按请求传入 `{ source, namespace }` 路由覆盖。仅把 `routeOverride` 当作受信的服务端输入。
526
558
 
527
559
  **它会执行迁移吗?**
528
- 不会。`syncSchema()` 只通过 introspection *读取*物理结构(introspect → 合并 overlay → 注册)。schema 变更 / DDL 是你所用迁移工具的职责。
560
+ 不会。`syncSchema()` 只通过 introspection *读取*物理结构(introspect → 合并 overlay → 注册)。schema 变更 / DDL 是你所用迁移工具的职责。若想要一个起点,`store.generateDdl(backend)` 可从已注册 schema 渲染 `CREATE TABLE` 文本 —— 但它只是纯文本生成:绝不执行、也不写入 DDL。
529
561
 
530
562
  **能否在不运行的情况下查看生成的查询?**
531
563
  可以 —— `store.buildPipeline(gql, params)` 返回编译后的计划(`{ tokens, ast, pipeline, projection }`),不执行,也不应用权限/计算列。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-store",
3
- "version": "2.0.4",
3
+ "version": "2.2.0",
4
4
  "description": "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",
5
5
  "main": "src/index.js",
6
6
  "files": [
package/src/crud/exec.js CHANGED
@@ -15,6 +15,7 @@
15
15
  const { PermissionError, getContext } = require('../permission');
16
16
  const datasource = require('../datasource');
17
17
  const { execMongo } = require('../executors/mongo');
18
+ const { emit: _emitFeedback } = require('../feedback');
18
19
  const { get: _getSchema } = require('../schema');
19
20
 
20
21
  const _PHASE1_IDS = /^\{\{phase1\.ids\}\}$/;
@@ -27,6 +28,36 @@ const _STEP_PH = /^\{\{step\.(\d+)\._id\}\}$/;
27
28
  */
28
29
  const _PERM_PREFIX = 'ERR_PERMISSION:';
29
30
 
31
+ /**
32
+ * 档位类错误识别:core text2query 档门禁统一携带 `ERR_TEXT2QUERY:` 稳定前缀
33
+ * (见 core `command/mod.rs::ERR_TEXT2QUERY`),同上按前缀映射。命中即 emit
34
+ * 反馈事件 `profile_blocked`(自动反馈原则:允许拦截,禁止静默)。
35
+ */
36
+ const _PROFILE_PREFIX = 'ERR_TEXT2QUERY:';
37
+
38
+ /**
39
+ * 从 core 文案 `... [$feature](功能收缩)` 中提取门禁项名;无 `[..]` 时留白
40
+ * (null),不伪造 feature —— 缺值必须显式暴露(禁静默兜底)。
41
+ */
42
+ const _FEATURE_RE = /\[(.+?)\]/;
43
+
44
+ /** 档位拦截反馈的统一提示(反馈事件契约 §4.6 的一部分;单点定义防文案漂移) */
45
+ const _PROFILE_HINT = '上游(LLM 产出的 GQL / 调用方入参)越界;text2query 档白名单见 SKILL.md §后端无关性与边界';
46
+
47
+ /**
48
+ * 档位(profile)拒绝:text2query 档违反功能收缩 / 硬限制
49
+ *
50
+ * 与权限错误(`PermissionError`,403)区分:档位拒绝是**调用方合约违反**(400),
51
+ * 非授权问题(见执行文档 §4.4)。`status` 供上层(HTTP 网关等)映射响应码。
52
+ */
53
+ class ProfileViolation extends Error {
54
+ constructor(message, status = 400) {
55
+ super(message);
56
+ this.name = 'ProfileViolation';
57
+ this.status = status;
58
+ }
59
+ }
60
+
30
61
  /** 设置数据源连接映射(对 `../datasource` 的路由入口做包内透出) */
31
62
  const setConnections = datasource.setConnections;
32
63
 
@@ -45,7 +76,14 @@ function _ctx() {
45
76
  return getContext() ?? null;
46
77
  }
47
78
 
48
- /** 绑定层调用包装:权限类错误(`ERR_PERMISSION:` 前缀)映射为 PermissionError */
79
+ /**
80
+ * 绑定层调用包装:
81
+ * - 权限类错误(`ERR_PERMISSION:` 前缀)→ PermissionError
82
+ * - 档位类错误(`ERR_TEXT2QUERY:` 前缀)→ emit `profile_blocked` 反馈 + ProfileViolation
83
+ *
84
+ * 按前缀映射而非具体文案(core 文案可自由调整,映射不随文案漂移而静默失效)。
85
+ * 其余异常原样上抛(不吞错)。
86
+ */
49
87
  function _call(fn) {
50
88
  try {
51
89
  return fn();
@@ -54,6 +92,20 @@ function _call(fn) {
54
92
  if (typeof msg === 'string' && msg.startsWith(_PERM_PREFIX)) {
55
93
  throw new PermissionError(msg.slice(_PERM_PREFIX.length));
56
94
  }
95
+ if (typeof msg === 'string' && msg.startsWith(_PROFILE_PREFIX)) {
96
+ const detail = msg.slice(_PROFILE_PREFIX.length);
97
+ const m = _FEATURE_RE.exec(detail);
98
+ _emitFeedback({
99
+ type: 'profile_blocked',
100
+ code: 'profileBlocked',
101
+ layer: 'core',
102
+ profile: 'text2query',
103
+ feature: m ? m[1] : null,
104
+ message: detail,
105
+ hint: _PROFILE_HINT,
106
+ });
107
+ throw new ProfileViolation(detail);
108
+ }
57
109
  throw e;
58
110
  }
59
111
  }
@@ -116,6 +168,8 @@ module.exports = {
116
168
  _nowFor,
117
169
  _ctx,
118
170
  _call,
171
+ ProfileViolation,
172
+ _PROFILE_HINT,
119
173
  _exec,
120
174
  _execOn,
121
175
  _substitute,
package/src/crud/index.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * - [`mutation`]:mutation / upsert
20
20
  */
21
21
 
22
- const { setConnections, setDb, _nowFor, _ctx, _call, _exec, _substitute, resolvePlaceholders } = require('./exec');
22
+ const { setConnections, setDb, _nowFor, _ctx, _call, ProfileViolation, _exec, _substitute, resolvePlaceholders } = require('./exec');
23
23
  const { _generateId, _truthy, _newIdPool } = require('./id');
24
24
  const { query, queryOne, queryWithCount, queryFederated } = require('./query');
25
25
  const { insert, insertMany, update, updateMany, remove, exists, count } = require('./write');
@@ -41,6 +41,7 @@ module.exports = {
41
41
  count,
42
42
  mutation,
43
43
  upsert,
44
+ ProfileViolation,
44
45
  // ── Host 契约件(供跨语言同构契约测试与高级用法;下划线表示内部语义) ──
45
46
  _substitute,
46
47
  resolvePlaceholders,
package/src/crud/query.js CHANGED
@@ -5,9 +5,34 @@
5
5
  * / 跨库联邦(逐源执行 → 内存 hash join)
6
6
  */
7
7
 
8
- const { core: _core, getAsyncFn } = require('../schema');
8
+ const { core: _core, getAsyncFn, getProfile } = require('../schema');
9
9
  const { emit: _emitFeedback } = require('../feedback');
10
- const { _call, _ctx, _exec, _execOn, resolvePlaceholders } = require('./exec');
10
+ const {
11
+ _call, _ctx, _exec, _execOn, resolvePlaceholders, ProfileViolation, _PROFILE_HINT,
12
+ } = require('./exec');
13
+
14
+ /**
15
+ * Host 兜底:text2query 档禁用 `routeOverride`(受信参数,禁 AI 侧指定)
16
+ *
17
+ * 判决唯一在 core(执行文档 §4.2 ⑤:各 `planQuery*` 入口已判并 Err);此为第二层
18
+ * 防护——即使 core 判决被绕过,Host 也不放行受信参数(CWE-639)。命中即 emit
19
+ * `profile_blocked`,`layer: 'host'` 本身即反馈:core 层未拦住,须回溯加固
20
+ * (自动反馈原则:允许拦截,禁止静默)。
21
+ */
22
+ function _guardRouteOverride(routeOverride) {
23
+ if (routeOverride == null || getProfile() !== 'text2query') return;
24
+ const detail = 'text2query 档禁用 route_override(受信参数,禁 AI 侧指定)';
25
+ _emitFeedback({
26
+ type: 'profile_blocked',
27
+ code: 'profileBlocked',
28
+ layer: 'host',
29
+ profile: 'text2query',
30
+ feature: 'route_override',
31
+ message: detail,
32
+ hint: _PROFILE_HINT,
33
+ });
34
+ throw new ProfileViolation(detail);
35
+ }
11
36
 
12
37
  /** 执行读命令序列:find 快路径 / 两阶段(取 ID → 关联 → 还原排序)/ 标准聚合 */
13
38
  async function _runQueryPlan(plan) {
@@ -44,12 +69,14 @@ async function _finalize(plan, items) {
44
69
  * 权限/计算列仍按结构 schema 判定(见 multi-datasource-routing-plan.md §6)。
45
70
  */
46
71
  async function query(gql, params = null, routeOverride = null) {
72
+ _guardRouteOverride(routeOverride);
47
73
  const plan = _call(() => _core.planQuery(gql, params ?? {}, _ctx(), routeOverride));
48
74
  return _finalize(plan, await _runQueryPlan(plan));
49
75
  }
50
76
 
51
77
  /** GQL 查询(返回单条)—— 走 core `planQueryOne`:未显式 `$limit` 时下推 `$limit(1)` */
52
78
  async function queryOne(gql, params = null, routeOverride = null) {
79
+ _guardRouteOverride(routeOverride);
53
80
  const plan = _call(() => _core.planQueryOne(gql, params ?? {}, _ctx(), routeOverride));
54
81
  const items = await _finalize(plan, await _runQueryPlan(plan));
55
82
  return items.length ? items[0] : null;
@@ -106,6 +133,7 @@ async function queryFederated(gql, params = null) {
106
133
  * pageSize 上限 5000,防止拖库。
107
134
  */
108
135
  async function queryWithCount(gql, params = null, routeOverride = null) {
136
+ _guardRouteOverride(routeOverride);
109
137
  const plan = _call(() =>
110
138
  _core.planQueryWithCount(gql, params ?? {}, _ctx(), null, routeOverride));
111
139
  const items = await _finalize(plan, await _runQueryPlan(plan));
@@ -119,4 +147,6 @@ async function queryWithCount(gql, params = null, routeOverride = null) {
119
147
  };
120
148
  }
121
149
 
122
- module.exports = { query, queryOne, queryWithCount, queryFederated };
150
+ module.exports = {
151
+ query, queryOne, queryWithCount, queryFederated, _guardRouteOverride,
152
+ };
package/src/datasource.js CHANGED
@@ -235,6 +235,39 @@ async function execSql(source, connection, cmd) {
235
235
  return executors.shapeResult(cmd, out);
236
236
  }
237
237
 
238
+ /** 原生 SQL 入口的显式错误(非 SQL 源 / 执行器未接入) */
239
+ class RawSqlError extends Error {
240
+ constructor(message) {
241
+ super(message);
242
+ this.name = 'RawSqlError';
243
+ }
244
+ }
245
+
246
+ /**
247
+ * 在指定 SQL 源上执行原生 SQL(Host 层逃生口,绕开 core 的 dialectTranslate)
248
+ *
249
+ * - 事务作用域内经 `connectionFor` 落到事务专用连接 → 支持 SELECT ... FOR UPDATE;
250
+ * - 占位符沿用各后端原生风格(mysql/sqlite 用 `?`,postgres 用 `$1..$n`);
251
+ * - 仅支持 SQL 源;Mongo 源显式报错(绝不静默);
252
+ * - `isWrite=false` 视为读(取行);`true` 视为写(取影响行数);
253
+ * - 返回 `{ rows, affectedRows }`。
254
+ * 对齐 py_store/datasource.py#execute_raw。
255
+ */
256
+ async function executeRaw(source, sql, params = [], isWrite = false) {
257
+ const conn = connectionFor(source);
258
+ if (!conn || typeof conn.kind !== 'string') {
259
+ throw new RawSqlError(
260
+ `数据源 ${source} 不是 SQL 源(原生 SQL 入口仅支持 mysql/postgres/sqlite)`,
261
+ );
262
+ }
263
+ if (typeof conn.exec !== 'function') {
264
+ throw new RawSqlError(`SQL 数据源 ${source}(${conn.kind}) 的执行器未接入`);
265
+ }
266
+ const stmt = { text: sql, params: Array.from(params || []), isWrite: Boolean(isWrite) };
267
+ const out = await conn.exec({ stmts: [stmt] });
268
+ return { rows: out.rows ?? null, affectedRows: Number(out.affectedRows || 0) };
269
+ }
270
+
238
271
  module.exports = {
239
272
  DEFAULT_SOURCE,
240
273
  setConnections,
@@ -250,5 +283,7 @@ module.exports = {
250
283
  dbOfSchema,
251
284
  route,
252
285
  execSql,
286
+ executeRaw,
253
287
  PushdownUnsupportedError,
288
+ RawSqlError,
254
289
  };
package/src/ddl.js ADDED
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * DDL 生成(schema def → CREATE TABLE 文本;纯函数,不连库、不回写)
5
+ *
6
+ * 与 core 契约严格对齐(schema→DDL 单向映射):
7
+ * - 标量字段按声明类型建列;object/array 字段建 **JSON 列**(MySQL `JSON` / PG `jsonb` /
8
+ * SQLite `TEXT`,同 core dialect::Backend::json_type_name)——落单列存 JSON 文本,
9
+ * 读侧由 core row::parse_json_col 还原为嵌套对象,跨后端对齐 Mongo 嵌套文档;
10
+ * - 每表必建 __present 哨兵列(形态 ,f1,f2,;同 core write/insert.rs::present_value);
11
+ * - timestamps !== false → 追加 createdAt / updatedAt(同 core schema/registry.rs::add_timestamp_fields);
12
+ * - 归档表 <collection>_deleted 由 registry 自动派生,本模块按已注册 def 逐表生成(不特判);
13
+ * - 不生成 CREATE INDEX(SQL 后端不建索引,schema.indexes 仅元数据,铁律 6)。
14
+ *
15
+ * 生成器只产出文本、不执行 —— 不违反铁律 6(绝不写 DDL 回库)。
16
+ * 对齐 py_store/ddl.py(两端输出逐字节一致)。
17
+ */
18
+
19
+ const { emit: _emitFeedback } = require('./feedback');
20
+ const schema = require('./schema');
21
+
22
+ const BACKENDS = ['mysql', 'postgres', 'sqlite'];
23
+
24
+ // schema 声明类型 → [mysql, postgres, sqlite] 列类型
25
+ const TYPES = {
26
+ string: ['VARCHAR(255)', 'TEXT', 'TEXT'],
27
+ int: ['INT', 'INTEGER', 'INTEGER'],
28
+ long: ['BIGINT', 'BIGINT', 'INTEGER'],
29
+ number: ['BIGINT', 'BIGINT', 'INTEGER'],
30
+ float: ['DOUBLE', 'DOUBLE PRECISION', 'REAL'],
31
+ double: ['DOUBLE', 'DOUBLE PRECISION', 'REAL'],
32
+ bool: ['TINYINT(1)', 'BOOLEAN', 'INTEGER'],
33
+ boolean: ['TINYINT(1)', 'BOOLEAN', 'INTEGER'],
34
+ datetime: ['BIGINT', 'BIGINT', 'INTEGER'],
35
+ date: ['BIGINT', 'BIGINT', 'INTEGER'],
36
+ };
37
+ const NON_COLUMN = ['object', 'array'];
38
+ // object/array 字段的列类型(JSON 文本列;同 core Backend::json_type_name)
39
+ const JSON_TYPE = ['JSON', 'jsonb', 'TEXT'];
40
+ const ID_TYPE = ['VARCHAR(64)', 'TEXT', 'TEXT'];
41
+ const PRESENT_TYPE = ['VARCHAR(255)', 'TEXT', 'TEXT'];
42
+ const TIMESTAMP_FIELDS = ['createdAt', 'updatedAt'];
43
+ const MYSQL_PRESENT_MAX = 255;
44
+
45
+ function idx(backend) {
46
+ return BACKENDS.indexOf(backend);
47
+ }
48
+
49
+ /** 标识符引用(与 core Backend::quote_ident 一致:mysql 反引号,其余双引号) */
50
+ function q(backend, ident) {
51
+ if (backend === 'mysql') return '`' + ident.replace(/`/g, '``') + '`';
52
+ return '"' + ident.replace(/"/g, '""') + '"';
53
+ }
54
+
55
+ function declaredType(fieldDef) {
56
+ return fieldDef && typeof fieldDef === 'object' ? fieldDef.type : fieldDef;
57
+ }
58
+
59
+ /** 返回 [[name, sqlType, pk]],顺序:声明的字段(标量 / object·array JSON 列)→ timestamps → __present */
60
+ function columns(defn, backend) {
61
+ const i = idx(backend);
62
+ const cols = [];
63
+ const fields = defn.fields || {};
64
+ for (const [name, fdef] of Object.entries(fields)) {
65
+ const ftype = declaredType(fdef);
66
+ if (name === '_id') {
67
+ cols.push([name, ID_TYPE[i], true]);
68
+ continue;
69
+ }
70
+ if (NON_COLUMN.includes(ftype)) {
71
+ // object/array → 单列 JSON 文本(同 core field_column_ref::Json)
72
+ cols.push([name, JSON_TYPE[i], false]);
73
+ continue;
74
+ }
75
+ if (!Object.prototype.hasOwnProperty.call(TYPES, ftype)) {
76
+ throw new Error(
77
+ `DDL 生成:字段 "${defn.name}.${name}" 类型 ${JSON.stringify(ftype)} 未知,支持 ${Object.keys(TYPES).sort()}`,
78
+ );
79
+ }
80
+ cols.push([name, TYPES[ftype][i], false]);
81
+ }
82
+ if (!cols.some((c) => c[2])) {
83
+ throw new Error(`DDL 生成:schema "${defn.name}" 缺少 _id 字段`);
84
+ }
85
+ if (defn.timestamps !== false) {
86
+ for (const ts of TIMESTAMP_FIELDS) {
87
+ if (!cols.some((c) => c[0] === ts)) cols.push([ts, TYPES.number[i], false]);
88
+ }
89
+ }
90
+ cols.push(['__present', PRESENT_TYPE[i], false]);
91
+ return cols;
92
+ }
93
+
94
+ /** MySQL __present VARCHAR(255) 容量校验:超限即告警(不静默) */
95
+ function warnPresentOverflow(defn, cols) {
96
+ const length = cols.reduce((n, c) => n + c[0].length, 0) + cols.length + 1;
97
+ if (length > MYSQL_PRESENT_MAX) {
98
+ _emitFeedback({
99
+ type: 'ddl_present_overflow',
100
+ code: 'ddlPresentOverflow',
101
+ layer: 'host',
102
+ message: `表 ${defn.collection} 的 __present 预估长度 ${length} 超过 MySQL VARCHAR(255)`,
103
+ hint: '为该表改用 TEXT 列,或减少标量字段;否则写入会被截断/报错,导致 $eq:null / $exists 三态判定错误',
104
+ schema: defn.name,
105
+ });
106
+ }
107
+ }
108
+
109
+ function createTable(defn, backend) {
110
+ const table = defn.collection || defn.name;
111
+ const cols = columns(defn, backend);
112
+ if (backend === 'mysql') warnPresentOverflow(defn, cols);
113
+ const lines = [];
114
+ for (const [name, ctype, pk] of cols) {
115
+ if (pk && backend === 'mysql') lines.push(` ${q(backend, name)} ${ctype} NOT NULL`);
116
+ else if (pk) lines.push(` ${q(backend, name)} ${ctype} PRIMARY KEY`);
117
+ else lines.push(` ${q(backend, name)} ${ctype}`);
118
+ }
119
+ if (backend === 'mysql') lines.push(` PRIMARY KEY (${q(backend, '_id')})`);
120
+ return `CREATE TABLE ${q(backend, table)} (\n` + lines.join(',\n') + '\n);';
121
+ }
122
+
123
+ /** 生成 DDL 文本(多表以空行分隔);backend ∈ mysql/postgres/sqlite */
124
+ function generate(backend, names) {
125
+ if (!BACKENDS.includes(backend)) {
126
+ throw new Error(`DDL 生成:不支持的后端 ${JSON.stringify(backend)}(支持 ${BACKENDS.join('/')})`);
127
+ }
128
+ const targets = names && names.length ? Array.from(names) : schema.list();
129
+ return targets.map((n) => createTable(schema.get(n), backend)).join('\n\n');
130
+ }
131
+
132
+ module.exports = { generate };
package/src/index.js CHANGED
@@ -21,8 +21,11 @@
21
21
  * const items = await store.query('Model($condition:@c0) { field1, field2 }', { c0: {} });
22
22
  */
23
23
 
24
+ const { AsyncLocalStorage } = require('node:async_hooks');
25
+
24
26
  const crud = require('./crud');
25
27
  const datasource = require('./datasource');
28
+ const ddl = require('./ddl');
26
29
  const executors = require('./executors');
27
30
  const feedback = require('./feedback');
28
31
  const introspect = require('./introspect');
@@ -30,6 +33,29 @@ const permission = require('./permission');
30
33
  const schema = require('./schema');
31
34
  const { syncSchema } = require('./sync');
32
35
 
36
+ /** 档位 AsyncLocalStorage:记录「进入 text2query 前的原档」,供退出恢复(嵌套安全) */
37
+ const _profileAls = new AsyncLocalStorage();
38
+
39
+ /**
40
+ * 以 text2query 档执行(功能收缩 + 硬限制),退出恢复原档位。
41
+ *
42
+ * AI 问数链路入口;与 permission.scopedRoles 同构(token-set/reset,嵌套安全)。
43
+ * 档位是 core 进程级状态(非本 ALS 隔离),ALS 仅记录「进入时的原档」以便正确恢复,
44
+ * 使异步 / 嵌套调用各自回到自己进入前的档位。进入档位即等效强制携带用户上下文
45
+ * (core `ensureProfileCtx`,见执行文档 §4.2)。
46
+ */
47
+ async function text2query(fn) {
48
+ const prev = schema.getProfile();
49
+ schema.setProfile('text2query');
50
+ return _profileAls.run(prev, async () => {
51
+ try {
52
+ return await fn();
53
+ } finally {
54
+ schema.setProfile(prev);
55
+ }
56
+ });
57
+ }
58
+
33
59
  class Store {
34
60
  // ── Schema 管理 ──
35
61
  register(defn) {
@@ -113,6 +139,33 @@ class Store {
113
139
  return syncSchema(opts);
114
140
  }
115
141
 
142
+ // ── 事务 + 原生 SQL(复用 datasource.runInTransaction;见 README「事务边界」)──
143
+ /**
144
+ * 事务作用域:单 SQL 源「同连接 + 同事务」执行 fn(复用 runInTransaction)
145
+ *
146
+ * fn 内 executeRaw / CRUD 均落到该源的事务连接(commit/rollback 一体);
147
+ * Mongo 源或执行器未实现 withTransaction 时按原样执行(跨源无法原子),
148
+ * 绝不静默假装已事务化。单源场景 source 传 'default'。
149
+ */
150
+ async transaction(source, fn) {
151
+ return datasource.runInTransaction(source, fn);
152
+ }
153
+
154
+ /**
155
+ * 在指定 SQL 源执行原生 SQL(事务内可用;占位符按各后端原生风格)
156
+ *
157
+ * mysql/sqlite 用 `?`,postgres 用 `$1..$n`;仅支持 SQL 源(Mongo 源抛 RawSqlError)。
158
+ * isWrite=false 取行(rows),true 取影响行数(affectedRows)。对齐 py-store store.execute_raw。
159
+ */
160
+ async executeRaw(source, sql, params, isWrite) {
161
+ return datasource.executeRaw(source, sql, params, isWrite);
162
+ }
163
+
164
+ /** 从已注册 schema def 生成指定后端 DDL 文本(纯函数,不连库、不回写;铁律 6) */
165
+ generateDdl(backend, names) {
166
+ return ddl.generate(backend, names);
167
+ }
168
+
116
169
  // ── 底层工具(调试/高级用法) ──
117
170
  /** 解析 GQL 并构建 pipeline,返回 `{tokens, ast, pipeline, projection}` */
118
171
  buildPipeline(gql, params) {
@@ -133,6 +186,26 @@ class Store {
133
186
  return schema.requireContext();
134
187
  }
135
188
 
189
+ // ── 查询档位(判决唯一在 core):standard 默认放开 / text2query 功能收缩 ──
190
+ /**
191
+ * 设置查询档位:`'standard'`(默认,功能最大化 + 跨 DB 对齐)/
192
+ * `'text2query'`(功能收缩 + 硬限制)。进入档即等效强制 ctx;
193
+ * 未知档由 core 抛错(禁静默回落默认档)。
194
+ */
195
+ setProfile(profile) {
196
+ return schema.setProfile(profile);
197
+ }
198
+
199
+ /** 当前查询档位字符串(对齐 py-store store.get_profile) */
200
+ getProfile() {
201
+ return schema.getProfile();
202
+ }
203
+
204
+ /** text2query 便捷上下文(进入设档、退出恢复;同 scopedRoles 的 token-set/reset) */
205
+ async text2query(fn) {
206
+ return text2query(fn);
207
+ }
208
+
136
209
  /** 设置数据源连接映射(多后端路由;对齐 py-store store.set_connections) */
137
210
  setConnections(connections) {
138
211
  return datasource.setConnections(connections);
@@ -163,6 +236,10 @@ class Store {
163
236
 
164
237
  /** 自定义权限错误(实例可被 store.PermissionError 捕获) */
165
238
  Store.prototype.PermissionError = permission.PermissionError;
239
+ /** 档位拒绝错误(实例可被 store.ProfileViolation 捕获;权限错误另见 PermissionError) */
240
+ Store.prototype.ProfileViolation = crud.ProfileViolation;
241
+ /** 原生 SQL 入口错误(实例可被 store.RawSqlError 捕获) */
242
+ Store.prototype.RawSqlError = datasource.RawSqlError;
166
243
 
167
244
  const store = new Store();
168
245
 
@@ -253,9 +330,13 @@ module.exports = {
253
330
  init,
254
331
  store,
255
332
  Store,
333
+ text2query,
256
334
  PermissionError: permission.PermissionError,
335
+ ProfileViolation: crud.ProfileViolation,
257
336
  PushdownUnsupportedError: datasource.PushdownUnsupportedError,
337
+ RawSqlError: datasource.RawSqlError,
258
338
  datasource,
339
+ ddl,
259
340
  schema,
260
341
  permission,
261
342
  crud,
package/src/schema.js CHANGED
@@ -115,9 +115,35 @@ function requireContext() {
115
115
  return core.requireContext();
116
116
  }
117
117
 
118
+ /**
119
+ * 设置查询档位:`'standard'`(默认,功能最大化 + 跨 DB 对齐)/
120
+ * `'text2query'`(功能收缩 + 硬限制)
121
+ *
122
+ * 判决唯一在 core;未知档位由 core 抛错(禁静默回落到默认档)。
123
+ */
124
+ function setProfile(profile) {
125
+ core.setProfile(profile);
126
+ }
127
+
128
+ /** 当前档位字符串(`'standard'` / `'text2query'`;对齐 py_store.schema.get_profile) */
129
+ function getProfile() {
130
+ return core.profile();
131
+ }
132
+
118
133
  /** 取 asyncFn 计算列实现(fnRef 缺省 = 计算列 key 名) */
119
134
  function getAsyncFn(fnRef) {
120
135
  return _asyncFns[fnRef];
121
136
  }
122
137
 
123
- module.exports = { core, register, get, has, list, setRequireContext, requireContext, getAsyncFn };
138
+ module.exports = {
139
+ core,
140
+ register,
141
+ get,
142
+ has,
143
+ list,
144
+ setRequireContext,
145
+ requireContext,
146
+ setProfile,
147
+ getProfile,
148
+ getAsyncFn,
149
+ };