nodejs-store 2.5.0 → 2.6.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 +16 -0
- package/README.zh-CN.md +15 -0
- package/package.json +1 -1
- package/src/crud/write.js +61 -5
- package/src/ddl.js +68 -11
- package/src/executors/index.js +15 -2
- package/src/executors/mongo.js +18 -3
- package/src/executors/mysql.js +5 -1
- package/src/executors/sqlite.js +7 -2
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
- [Schema reference](#schema-reference)
|
|
36
36
|
- [Advanced API](#advanced-api)
|
|
37
37
|
- [Transactions](#transaction-boundary)
|
|
38
|
+
- [Transactional capabilities](#transactional-capabilities)
|
|
38
39
|
- [FAQ](#faq)
|
|
39
40
|
- [Related projects](#related-projects)
|
|
40
41
|
|
|
@@ -210,6 +211,9 @@ GQL tree queries compile to a single native query per backend — never hand-wri
|
|
|
210
211
|
- **Permission context** — `AsyncLocalStorage`-based roles (`super_admin`/`admin`/`guest`/`creator`...), schema/field-level read/write whitelists, automatic owner-condition injection.
|
|
211
212
|
- **Multi-datasource & multi-tenant** — locate a schema by `(source, namespace, collection)`; re-target per request with a route override.
|
|
212
213
|
- **Async-first, Rust core** — built on the `mongodb` Node.js driver and a shared Rust core with SQL dialects.
|
|
214
|
+
- **Relation predicates in mutations** — filter `update` / `remove` by related-table fields, pushed down to all four backends (previously a silent no-op on MongoDB).
|
|
215
|
+
- **Autoincrement primary keys** — declare `_id` as `{ type: 'int', strategy: 'autoincrement' }` for database-assigned integer IDs, with explicit errors where autoincrement is impossible.
|
|
216
|
+
- **Index DDL** — `schema.indexes` compiles to real `CREATE [UNIQUE] INDEX` statements (per backend, byte-identical); the generator still only emits text.
|
|
213
217
|
|
|
214
218
|
## GQL syntax
|
|
215
219
|
|
|
@@ -562,6 +566,18 @@ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) }
|
|
|
562
566
|
- Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
|
|
563
567
|
- **Cross-source writes (no session)**: a single write call touching ≥2 datasources **cannot be atomic**; it runs sequentially and emits one `non_atomic_write` feedback event (`code: nonAtomic`, with the source list) — degradation is allowed, silence is not. Converge writes onto a single source, or wrap them in `store.session()` (which fails closed on cross-source writes).
|
|
564
568
|
|
|
569
|
+
## Transactional capabilities
|
|
570
|
+
|
|
571
|
+
Capabilities aimed at transactional workloads (orders, inventory — write contention plus
|
|
572
|
+
complex reads). Full details, semantics and the explicit-error list:
|
|
573
|
+
**[doc/transaction-capabilities.md](doc/transaction-capabilities.md)** ·
|
|
574
|
+
[中文](doc/transaction-capabilities.zh-CN.md).
|
|
575
|
+
|
|
576
|
+
- **Relation predicates in mutations** — `updateMany('Inventory', { product: { category: 'meat' } }, { $inc: { stock: 10 } })`: condition keys matching a declared relation become a semi/anti-join, normalized into a preCommand (aggregate fetching `_id`s) plus `_id $in`.
|
|
577
|
+
- **`$group by` one-relation paths** — `by: ['product.category']` compiles to `$lookup`+`$unwind` (Mongo) / `LEFT JOIN` (SQL); `many` paths fail explicitly (fan-out breaks count semantics).
|
|
578
|
+
- **Autoincrement PKs** — `_id: { type: 'int', strategy: 'autoincrement' }`; PG/SQLite read back via `INSERT…RETURNING`, MySQL via insertId; MongoDB and `insertMany` fail explicitly with `AUTOINCREMENT_NOT_SUPPORTED` (no silent ObjectId substitution).
|
|
579
|
+
- **Index DDL** — `schema.indexes` (MongoDB shape) → `CREATE [UNIQUE] INDEX idx_<table>_<cols>` in `ddl.generate`, byte-identical across MySQL/PostgreSQL/SQLite.
|
|
580
|
+
|
|
565
581
|
## FAQ
|
|
566
582
|
|
|
567
583
|
**How do I use one schema for both MongoDB and PostgreSQL in Node.js?**
|
package/README.zh-CN.md
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
- [Schema 参考](#schema-参考)
|
|
35
35
|
- [高级 API](#高级-api)
|
|
36
36
|
- [事务边界](#事务边界)
|
|
37
|
+
- [事务型能力](#事务型能力)
|
|
37
38
|
- [常见问题](#常见问题)
|
|
38
39
|
- [相关项目](#相关项目)
|
|
39
40
|
|
|
@@ -209,6 +210,9 @@ GQL 树查询会编译为每个后端一条原生查询 —— 再也不必手
|
|
|
209
210
|
- **权限上下文** —— 基于 `AsyncLocalStorage` 的角色(`super_admin`/`admin`/`guest`/`creator`...)、schema/字段级读写白名单、自动属主条件注入。
|
|
210
211
|
- **多数据源 & 多租户** —— 通过 `(source, namespace, collection)` 定位 schema;按请求用路由覆盖重新定向。
|
|
211
212
|
- **异步优先,Rust 核心** —— 基于 `mongodb` Node.js 驱动与共享的 Rust 核心(含 SQL 方言)。
|
|
213
|
+
- **mutation 关系谓词** —— `update` / `remove` 按关联表字段过滤,下推到全部四个后端(此前 MongoDB 侧是静默 no-op)。
|
|
214
|
+
- **自增主键** —— `_id` 声明 `{ type: 'int', strategy: 'autoincrement' }` 即用数据库自增整数 ID;做不到自增的场景显式报错。
|
|
215
|
+
- **索引 DDL** —— `schema.indexes` 编译为真实 `CREATE [UNIQUE] INDEX` 语句(按后端、逐字节一致);生成器仍只产文本。
|
|
212
216
|
|
|
213
217
|
## GQL 语法
|
|
214
218
|
|
|
@@ -566,6 +570,17 @@ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) }
|
|
|
566
570
|
`non_atomic_write` 反馈(`code: nonAtomic`,含涉及源列表)——允许降级、禁止静默。
|
|
567
571
|
把写收敛到单源,或放入 `store.session()` 内(后者对跨源写直接 fail-closed)。
|
|
568
572
|
|
|
573
|
+
## 事务型能力
|
|
574
|
+
|
|
575
|
+
面向事务型业务场景(订单、库存——写竞争 + 复杂读)的能力增补。完整语义、用法与显式报错清单:
|
|
576
|
+
**[doc/transaction-capabilities.zh-CN.md](doc/transaction-capabilities.zh-CN.md)** ·
|
|
577
|
+
[English](doc/transaction-capabilities.md).
|
|
578
|
+
|
|
579
|
+
- **mutation 关系谓词** —— `updateMany('Inventory', { product: { category: 'meat' } }, { $inc: { stock: 10 } })`:条件键命中已声明关系即 semi/anti-join,归一为 preCommand(aggregate 取 `_id`)+ `_id $in`。
|
|
580
|
+
- **`$group by` one 关系路径** —— `by: ['product.category']` 编译为 `$lookup`+`$unwind`(Mongo)/ `LEFT JOIN`(SQL);many 路径显式报错(扇出破坏计数语义)。
|
|
581
|
+
- **自增主键** —— `_id: { type: 'int', strategy: 'autoincrement' }`;PG/SQLite 经 `INSERT…RETURNING` 回读、MySQL 经 insertId;MongoDB 与 `insertMany` 显式报 `AUTOINCREMENT_NOT_SUPPORTED`(禁 ObjectId 静默顶替)。
|
|
582
|
+
- **索引 DDL** —— `schema.indexes`(Mongo 形态)→ `ddl.generate` 产出 `CREATE [UNIQUE] INDEX idx_<表>_<字段>`,MySQL/PostgreSQL/SQLite 三方言逐字节一致。
|
|
583
|
+
|
|
569
584
|
## 常见问题
|
|
570
585
|
|
|
571
586
|
**如何在 Node.js 中让一份 schema 同时用于 MongoDB 和 PostgreSQL?**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodejs-store",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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/write.js
CHANGED
|
@@ -24,8 +24,14 @@ async function insert(schemaName, data, routeOverride = null) {
|
|
|
24
24
|
const plan = _call(() =>
|
|
25
25
|
_core.planInsert(schemaName, data ?? null, _nowFor(schemaName), s.idPrefix ? _generateId(s) : '', _ctx(),
|
|
26
26
|
routeOverride));
|
|
27
|
-
await _exec(plan.command);
|
|
28
|
-
|
|
27
|
+
const result = await _exec(plan.command);
|
|
28
|
+
let returns = plan.returns;
|
|
29
|
+
// 阶段2:autoincrement 主键 —— 执行器已回读自增值,returns 补 `_id`
|
|
30
|
+
if (returns && typeof returns === 'object' && !returns._id
|
|
31
|
+
&& result && typeof result === 'object' && result._id !== undefined && result._id !== null) {
|
|
32
|
+
returns = { ...returns, _id: result._id };
|
|
33
|
+
}
|
|
34
|
+
return returns;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** 批量插入(带权限检查,自动生成 _id 和时间戳;空数组直接返回空) */
|
|
@@ -33,6 +39,14 @@ async function insertMany(schemaName, docs, routeOverride = null) {
|
|
|
33
39
|
if (!Array.isArray(docs) || !docs.length) return [];
|
|
34
40
|
|
|
35
41
|
const s = _getSchema(schemaName);
|
|
42
|
+
// 阶段2(no-error-masking):autoincrement 的批量自增值回读不可靠(MySQL 批量
|
|
43
|
+
// insertId 仅首行、且并发插入会留间隙)→ 显式报错,不静默产出错误 _id
|
|
44
|
+
const idFdef = (s.fields || {})._id || {};
|
|
45
|
+
if (idFdef.strategy === 'autoincrement' && docs.some((d) => !(d && d._id))) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'AUTOINCREMENT_NOT_SUPPORTED: insertMany 不支持 autoincrement schema'
|
|
48
|
+
+ '(批量自增值回读不可靠);请逐条 insert 或显式提供 _id');
|
|
49
|
+
}
|
|
36
50
|
const plan = _call(() => _core.planInsertMany(
|
|
37
51
|
schemaName,
|
|
38
52
|
docs,
|
|
@@ -78,11 +92,46 @@ async function update(schemaName, condition, data, options = null, routeOverride
|
|
|
78
92
|
return runAtomic(sources, doRun);
|
|
79
93
|
}
|
|
80
94
|
|
|
95
|
+
/** 执行带 `preCommand` 的命令(阶段1:mutation 关系谓词归一)。
|
|
96
|
+
* preCommand(aggregate 取命中 `_id`)先行执行,把 `_id` 列表回填进主命令 filter 的
|
|
97
|
+
* `$in` 占位(core 注入 `"__REL_PRED_IDS__"`);空集 → `$in: []`(各后端均不命中任何行)。 */
|
|
98
|
+
async function execWithPre(command) {
|
|
99
|
+
const pre = command && command.preCommand;
|
|
100
|
+
if (!pre) return _exec(command);
|
|
101
|
+
const preRows = await _exec(pre);
|
|
102
|
+
const ids = (preRows || []).filter((d) => d && '_id' in d).map((d) => d._id);
|
|
103
|
+
return fillPreIds(command, ids);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 把 preCommand 取得的 `_id` 列表回填进命令 filter 的 `$in` 占位(递归查找后执行)。
|
|
107
|
+
* core 注入的占位可能位于 `$and` 数组内(改写条件已有其他键时),故递归遍历。 */
|
|
108
|
+
function fillPreIds(command, ids) {
|
|
109
|
+
const main = { ...command };
|
|
110
|
+
delete main.preCommand;
|
|
111
|
+
const walk = (node) => {
|
|
112
|
+
if (Array.isArray(node)) {
|
|
113
|
+
for (const it of node) walk(it);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (node && typeof node === 'object') {
|
|
117
|
+
for (const [k, v] of Object.entries(node)) {
|
|
118
|
+
if (v && typeof v === 'object' && v.$in === '__REL_PRED_IDS__') {
|
|
119
|
+
node[k] = { $in: [...ids] };
|
|
120
|
+
} else {
|
|
121
|
+
walk(v);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
if (main.filter) walk(main.filter);
|
|
127
|
+
return _exec(main);
|
|
128
|
+
}
|
|
129
|
+
|
|
81
130
|
/** 批量更新(支持原生操作符) */
|
|
82
131
|
async function updateMany(schemaName, condition, data, routeOverride = null) {
|
|
83
132
|
const out = _call(() =>
|
|
84
133
|
_core.planUpdateMany(schemaName, condition ?? null, data ?? null, _nowFor(schemaName), _ctx(), routeOverride));
|
|
85
|
-
const result = await
|
|
134
|
+
const result = await execWithPre(out.command);
|
|
86
135
|
return { modifiedCount: result.modifiedCount };
|
|
87
136
|
}
|
|
88
137
|
|
|
@@ -95,8 +144,15 @@ async function remove(schemaName, condition, routeOverride = null) {
|
|
|
95
144
|
|
|
96
145
|
const doRemove = async () => {
|
|
97
146
|
let archivedCount = 0;
|
|
147
|
+
// 关系谓词:先执行 deleteCommand.preCommand 取命中 _id(归档 find 与删除共用同一列表)
|
|
148
|
+
const pre = out.deleteCommand && out.deleteCommand.preCommand;
|
|
149
|
+
let ids = null;
|
|
150
|
+
if (pre) {
|
|
151
|
+
const preRows = await _exec(pre);
|
|
152
|
+
ids = (preRows || []).filter((d) => d && '_id' in d).map((d) => d._id);
|
|
153
|
+
}
|
|
98
154
|
if (out.findCommand) {
|
|
99
|
-
const docs = await _exec(out.findCommand);
|
|
155
|
+
const docs = await (ids !== null ? fillPreIds(out.findCommand, ids) : _exec(out.findCommand));
|
|
100
156
|
if (docs.length) {
|
|
101
157
|
const arch = _call(() => _core.planArchiveDocs(schemaName, docs, _nowFor(schemaName), routeOverride));
|
|
102
158
|
await _exec(arch.command);
|
|
@@ -104,7 +160,7 @@ async function remove(schemaName, condition, routeOverride = null) {
|
|
|
104
160
|
}
|
|
105
161
|
}
|
|
106
162
|
|
|
107
|
-
const result = await _exec(out.deleteCommand);
|
|
163
|
+
const result = await (ids !== null ? fillPreIds(out.deleteCommand, ids) : _exec(out.deleteCommand));
|
|
108
164
|
return { deletedCount: result.deletedCount, archivedCount };
|
|
109
165
|
};
|
|
110
166
|
|
package/src/ddl.js
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - 每表必建 __present 哨兵列(形态 ,f1,f2,;同 core write/insert.rs::present_value);
|
|
11
11
|
* - timestamps !== false → 追加 createdAt / updatedAt(同 core schema/registry.rs::add_timestamp_fields);
|
|
12
12
|
* - 归档表 <collection>_deleted 由 registry 自动派生,本模块按已注册 def 逐表生成(不特判);
|
|
13
|
-
* -
|
|
13
|
+
* - schema.indexes(Mongo 形态 {keys: {f: 1|-1}, options/inline})→ CREATE [UNIQUE] INDEX
|
|
14
|
+
* (阶段 3 索引落地;原「仅元数据不建索引」铁律 6 子项按用户裁决放开,见
|
|
15
|
+
* common-store/事务型能力增补执行文档.md 附录 D)。与 py_store/ddl.py 逐字节对齐。
|
|
14
16
|
*
|
|
15
17
|
* 生成器只产出文本、不执行 —— 不违反铁律 6(绝不写 DDL 回库)。
|
|
16
18
|
* 对齐 py_store/ddl.py(两端输出逐字节一致)。
|
|
@@ -38,6 +40,10 @@ const NON_COLUMN = ['object', 'array'];
|
|
|
38
40
|
// object/array 字段的列类型(JSON 文本列;同 core Backend::json_type_name)
|
|
39
41
|
const JSON_TYPE = ['JSON', 'jsonb', 'TEXT'];
|
|
40
42
|
const ID_TYPE = ['VARCHAR(64)', 'TEXT', 'TEXT'];
|
|
43
|
+
// 阶段2:`_id` 声明 strategy=autoincrement 时的自增列类型(MySQL AUTO_INCREMENT 列
|
|
44
|
+
// 须被索引 —— 表级 PRIMARY KEY 满足;SQLite 语法要求 PRIMARY KEY AUTOINCREMENT 相邻,
|
|
45
|
+
// 由 createTable 的 pk+auto 分支拼接;PG 用 SERIAL)。与 py_store/ddl.py 逐字节对齐。
|
|
46
|
+
const ID_AUTO_TYPE = ['INT AUTO_INCREMENT', 'SERIAL', 'INTEGER'];
|
|
41
47
|
const PRESENT_TYPE = ['VARCHAR(255)', 'TEXT', 'TEXT'];
|
|
42
48
|
const TIMESTAMP_FIELDS = ['createdAt', 'updatedAt'];
|
|
43
49
|
const MYSQL_PRESENT_MAX = 255;
|
|
@@ -56,7 +62,7 @@ function declaredType(fieldDef) {
|
|
|
56
62
|
return fieldDef && typeof fieldDef === 'object' ? fieldDef.type : fieldDef;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
/** 返回 [[name, sqlType, pk]],顺序:声明的字段(标量 / object·array JSON 列)→ timestamps → __present */
|
|
65
|
+
/** 返回 [[name, sqlType, pk, auto]],顺序:声明的字段(标量 / object·array JSON 列)→ timestamps → __present */
|
|
60
66
|
function columns(defn, backend) {
|
|
61
67
|
const i = idx(backend);
|
|
62
68
|
const cols = [];
|
|
@@ -64,12 +70,14 @@ function columns(defn, backend) {
|
|
|
64
70
|
for (const [name, fdef] of Object.entries(fields)) {
|
|
65
71
|
const ftype = declaredType(fdef);
|
|
66
72
|
if (name === '_id') {
|
|
67
|
-
|
|
73
|
+
const strategy = fdef && typeof fdef === 'object' ? fdef.strategy : undefined;
|
|
74
|
+
cols.push([name, strategy === 'autoincrement' ? ID_AUTO_TYPE[i] : ID_TYPE[i], true,
|
|
75
|
+
strategy === 'autoincrement']);
|
|
68
76
|
continue;
|
|
69
77
|
}
|
|
70
78
|
if (NON_COLUMN.includes(ftype)) {
|
|
71
79
|
// object/array → 单列 JSON 文本(同 core field_column_ref::Json)
|
|
72
|
-
cols.push([name, JSON_TYPE[i], false]);
|
|
80
|
+
cols.push([name, JSON_TYPE[i], false, false]);
|
|
73
81
|
continue;
|
|
74
82
|
}
|
|
75
83
|
if (!Object.prototype.hasOwnProperty.call(TYPES, ftype)) {
|
|
@@ -77,17 +85,17 @@ function columns(defn, backend) {
|
|
|
77
85
|
`DDL 生成:字段 "${defn.name}.${name}" 类型 ${JSON.stringify(ftype)} 未知,支持 ${Object.keys(TYPES).sort()}`,
|
|
78
86
|
);
|
|
79
87
|
}
|
|
80
|
-
cols.push([name, TYPES[ftype][i], false]);
|
|
88
|
+
cols.push([name, TYPES[ftype][i], false, false]);
|
|
81
89
|
}
|
|
82
90
|
if (!cols.some((c) => c[2])) {
|
|
83
91
|
throw new Error(`DDL 生成:schema "${defn.name}" 缺少 _id 字段`);
|
|
84
92
|
}
|
|
85
93
|
if (defn.timestamps !== false) {
|
|
86
94
|
for (const ts of TIMESTAMP_FIELDS) {
|
|
87
|
-
if (!cols.some((c) => c[0] === ts)) cols.push([ts, TYPES.number[i], false]);
|
|
95
|
+
if (!cols.some((c) => c[0] === ts)) cols.push([ts, TYPES.number[i], false, false]);
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
|
-
cols.push(['__present', PRESENT_TYPE[i], false]);
|
|
98
|
+
cols.push(['__present', PRESENT_TYPE[i], false, false]);
|
|
91
99
|
return cols;
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -106,13 +114,35 @@ function warnPresentOverflow(defn, cols) {
|
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
|
|
117
|
+
/** schema.indexes → CREATE [UNIQUE] INDEX 语句列表(阶段 3 索引落地)。
|
|
118
|
+
* 索引名 `idx_<collection>_<f1>_<f2>`(对齐 SQL 常规命名);keys 值 1/-1 → ASC/DESC。 */
|
|
119
|
+
function indexStmts(defn, backend) {
|
|
120
|
+
const out = [];
|
|
121
|
+
const table = defn.collection || defn.name;
|
|
122
|
+
for (const idx of defn.indexes || []) {
|
|
123
|
+
if (!idx || typeof idx !== 'object') continue;
|
|
124
|
+
const keys = idx.keys;
|
|
125
|
+
if (!keys || typeof keys !== 'object' || !Object.keys(keys).length) continue;
|
|
126
|
+
const unique = Boolean(idx.unique || (idx.options && idx.options.unique));
|
|
127
|
+
const cols = Object.entries(keys)
|
|
128
|
+
.map(([k, v]) => `${q(backend, k)} ${v === -1 ? 'DESC' : 'ASC'}`)
|
|
129
|
+
.join(', ');
|
|
130
|
+
const name = 'idx_' + table + '_' + Object.keys(keys).join('_');
|
|
131
|
+
out.push(`CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${q(backend, name)} ON ${q(backend, table)} (${cols})`);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
function createTable(defn, backend) {
|
|
110
137
|
const table = defn.collection || defn.name;
|
|
111
138
|
const cols = columns(defn, backend);
|
|
112
139
|
if (backend === 'mysql') warnPresentOverflow(defn, cols);
|
|
113
140
|
const lines = [];
|
|
114
|
-
for (const [name, ctype, pk] of cols) {
|
|
115
|
-
if (pk && backend === '
|
|
141
|
+
for (const [name, ctype, pk, auto] of cols) {
|
|
142
|
+
if (pk && auto && backend === 'sqlite') {
|
|
143
|
+
// SQLite 语法要求 AUTOINCREMENT 紧跟 PRIMARY KEY
|
|
144
|
+
lines.push(` ${q(backend, name)} ${ctype} PRIMARY KEY AUTOINCREMENT`);
|
|
145
|
+
} else if (pk && backend === 'mysql') lines.push(` ${q(backend, name)} ${ctype} NOT NULL`);
|
|
116
146
|
else if (pk) lines.push(` ${q(backend, name)} ${ctype} PRIMARY KEY`);
|
|
117
147
|
else lines.push(` ${q(backend, name)} ${ctype}`);
|
|
118
148
|
}
|
|
@@ -120,13 +150,40 @@ function createTable(defn, backend) {
|
|
|
120
150
|
return `CREATE TABLE ${q(backend, table)} (\n` + lines.join(',\n') + '\n);';
|
|
121
151
|
}
|
|
122
152
|
|
|
123
|
-
/** 生成 DDL
|
|
153
|
+
/** 生成 DDL 文本(多表以空行分隔,每表 CREATE TABLE 后跟其 CREATE INDEX);backend ∈ mysql/postgres/sqlite
|
|
154
|
+
*
|
|
155
|
+
* 按表名去重:同名表只出一次 CREATE TABLE + 索引(防御 core 注册表出现重复名 ——
|
|
156
|
+
* 上游失守即告警,禁静默;对齐 py_store/ddl.py 的 seen_tables 防御)。 */
|
|
124
157
|
function generate(backend, names) {
|
|
125
158
|
if (!BACKENDS.includes(backend)) {
|
|
126
159
|
throw new Error(`DDL 生成:不支持的后端 ${JSON.stringify(backend)}(支持 ${BACKENDS.join('/')})`);
|
|
127
160
|
}
|
|
128
161
|
const targets = names && names.length ? Array.from(names) : schema.list();
|
|
129
|
-
|
|
162
|
+
const blocks = [];
|
|
163
|
+
const seenTables = new Set();
|
|
164
|
+
const dup = [];
|
|
165
|
+
for (const n of targets) {
|
|
166
|
+
const defn = schema.get(n);
|
|
167
|
+
const table = defn.collection || n;
|
|
168
|
+
if (seenTables.has(table)) {
|
|
169
|
+
dup.push(table);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
seenTables.add(table);
|
|
173
|
+
blocks.push(createTable(defn, backend));
|
|
174
|
+
blocks.push(...indexStmts(defn, backend));
|
|
175
|
+
}
|
|
176
|
+
if (dup.length) {
|
|
177
|
+
_emitFeedback({
|
|
178
|
+
type: 'ddl_duplicate_table',
|
|
179
|
+
code: 'ddlDuplicateTable',
|
|
180
|
+
layer: 'host',
|
|
181
|
+
message: `DDL 生成:表 ${[...new Set(dup)].sort()} 重复注册,已去重`,
|
|
182
|
+
hint: 'schema 注册表出现重复名(见 schemaDuplicateName 告警);修复注册侧根因',
|
|
183
|
+
backend,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return blocks.join('\n\n');
|
|
130
187
|
}
|
|
131
188
|
|
|
132
189
|
module.exports = { generate };
|
package/src/executors/index.js
CHANGED
|
@@ -86,8 +86,21 @@ function shapeResult(cmd, out) {
|
|
|
86
86
|
return (out.docs && out.docs[0]) || null;
|
|
87
87
|
case 'countDocuments':
|
|
88
88
|
return _scalar(out.rows);
|
|
89
|
-
case 'insertOne':
|
|
90
|
-
|
|
89
|
+
case 'insertOne': {
|
|
90
|
+
const doc = cmd.doc;
|
|
91
|
+
// 阶段2:autoincrement 主键 —— doc 无 `_id`(core 不注入)→ 从执行包络回读
|
|
92
|
+
// 自增值(PG/SQLite RETURNING 走 rows;MySQL/SQLite lastInsertRowid 走 insertId)
|
|
93
|
+
if (doc && !doc._id) {
|
|
94
|
+
let rid = null;
|
|
95
|
+
if (out.rows && out.rows[0] && Object.prototype.hasOwnProperty.call(out.rows[0], '_id')) {
|
|
96
|
+
rid = out.rows[0]._id;
|
|
97
|
+
} else if (out.insertId !== null && out.insertId !== undefined) {
|
|
98
|
+
rid = out.insertId;
|
|
99
|
+
}
|
|
100
|
+
if (rid !== null && rid !== undefined) return { ...doc, _id: rid };
|
|
101
|
+
}
|
|
102
|
+
return doc;
|
|
103
|
+
}
|
|
91
104
|
case 'insertMany':
|
|
92
105
|
return { insertedCount: (cmd.docs || []).length };
|
|
93
106
|
case 'updateMany':
|
package/src/executors/mongo.js
CHANGED
|
@@ -119,9 +119,19 @@ async function execMongo(db, cmd, session) {
|
|
|
119
119
|
const opts = _opts(session, cmd.projection ? { projection: cmd.projection } : undefined);
|
|
120
120
|
return coll.findOne(cmd.filter, opts);
|
|
121
121
|
}
|
|
122
|
-
case 'insertOne':
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
case 'insertOne': {
|
|
123
|
+
const doc = cmd.doc || {};
|
|
124
|
+
// 阶段2(no-error-masking):Mongo 无自增语义 —— `_id` 缺失的文档只可能来自
|
|
125
|
+
// 声明 strategy=autoincrement 的 schema(常规 schema 该形态已被 core 拦截)。
|
|
126
|
+
// 禁止 ObjectId 静默顶替自增契约,显式报错。
|
|
127
|
+
if (!doc._id) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
'AUTOINCREMENT_NOT_SUPPORTED: schema 声明了 strategy="autoincrement",'
|
|
130
|
+
+ 'MongoDB 后端无自增语义(禁 ObjectId 顶替);请使用 SQL 数据源');
|
|
131
|
+
}
|
|
132
|
+
await coll.insertOne(doc, _opts(session));
|
|
133
|
+
return doc;
|
|
134
|
+
}
|
|
125
135
|
case 'insertMany':
|
|
126
136
|
if (cmd.upsertById) {
|
|
127
137
|
// 归档幂等(core planArchiveDocs):按 _id 逐条覆盖 —— 「归档成功但删除失败」
|
|
@@ -131,6 +141,11 @@ async function execMongo(db, cmd, session) {
|
|
|
131
141
|
}
|
|
132
142
|
return { insertedCount: cmd.docs.length };
|
|
133
143
|
}
|
|
144
|
+
if (cmd.docs.some((d) => !(d && d._id))) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
'AUTOINCREMENT_NOT_SUPPORTED: schema 声明了 strategy="autoincrement",'
|
|
147
|
+
+ 'MongoDB 后端无自增语义(禁 ObjectId 顶替);请使用 SQL 数据源');
|
|
148
|
+
}
|
|
134
149
|
await coll.insertMany(cmd.docs, _opts(session));
|
|
135
150
|
return { insertedCount: cmd.docs.length };
|
|
136
151
|
case 'findOneAndUpdate':
|
package/src/executors/mysql.js
CHANGED
|
@@ -30,6 +30,7 @@ function create(driver, _options = {}) {
|
|
|
30
30
|
let docs = null;
|
|
31
31
|
let rows = null;
|
|
32
32
|
let affectedRows = 0;
|
|
33
|
+
let insertId = null;
|
|
33
34
|
for (const stmt of plan.stmts) {
|
|
34
35
|
const [raw, fields] = await conn.execute(stmt.text, stmt.params || []);
|
|
35
36
|
if (Array.isArray(raw)) {
|
|
@@ -37,9 +38,12 @@ function create(driver, _options = {}) {
|
|
|
37
38
|
if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
|
|
38
39
|
} else {
|
|
39
40
|
affectedRows = Number(raw.affectedRows || 0);
|
|
41
|
+
// 阶段2:autoincrement 主键写后自增值回读(MySQL 无 RETURNING,insertId =
|
|
42
|
+
// 本连接最近一次 INSERT 生成的自增值)
|
|
43
|
+
insertId = Number(raw.insertId);
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
return { docs, rows, affectedRows };
|
|
46
|
+
return { docs, rows, affectedRows, insertId };
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
package/src/executors/sqlite.js
CHANGED
|
@@ -36,6 +36,7 @@ function create(db, _options = {}) {
|
|
|
36
36
|
let docs = null;
|
|
37
37
|
let rows = null;
|
|
38
38
|
let affectedRows = 0;
|
|
39
|
+
let insertId = null;
|
|
39
40
|
for (const stmt of plan.stmts) {
|
|
40
41
|
const params = _bind(stmt.params);
|
|
41
42
|
// 带 RETURNING 的写语句同样返回行 → 必须用 all() 取回;其余写语句用 run() 取影响行数
|
|
@@ -43,10 +44,14 @@ function create(db, _options = {}) {
|
|
|
43
44
|
rows = db.prepare(stmt.text).all(...params);
|
|
44
45
|
if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
|
|
45
46
|
} else {
|
|
46
|
-
|
|
47
|
+
const info = db.prepare(stmt.text).run(...params);
|
|
48
|
+
affectedRows = Number(info.changes || 0);
|
|
49
|
+
// 阶段2:autoincrement 主键写后自增值回读(非 RETURNING 的 INSERT 走
|
|
50
|
+
// lastInsertRowid;带 RETURNING 的写语句走上方 rows 分支)
|
|
51
|
+
insertId = Number(info.lastInsertRowid);
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
|
-
return { docs, rows, affectedRows };
|
|
54
|
+
return { docs, rows, affectedRows, insertId };
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
/** 显式事务句柄:BEGIN + 幂等 commit/rollback;release 为 no-op(单连接不归还) */
|