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 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.5.0",
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
- return plan.returns;
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 _exec(out.command);
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
- * - 不生成 CREATE INDEX(SQL 后端不建索引,schema.indexes 仅元数据,铁律 6)。
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
- cols.push([name, ID_TYPE[i], true]);
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 === 'mysql') lines.push(` ${q(backend, name)} ${ctype} NOT NULL`);
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 文本(多表以空行分隔);backend ∈ mysql/postgres/sqlite */
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
- return targets.map((n) => createTable(schema.get(n), backend)).join('\n\n');
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 };
@@ -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
- return cmd.doc;
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':
@@ -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
- await coll.insertOne(cmd.doc, _opts(session));
124
- return cmd.doc;
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':
@@ -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
  /**
@@ -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
- affectedRows = Number(db.prepare(stmt.text).run(...params).changes || 0);
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(单连接不归还) */