nodejs-store 2.4.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
 
@@ -316,9 +320,26 @@ async function transfer() {
316
320
  await store.transaction('default', transfer);
317
321
  ```
318
322
 
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.
323
+ - `store.transaction(source, fn)` opens a transaction scope on one 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 are probed at runtime (replica set / sharded) and wrapped in a session transaction; on standalone or probe failure `fn` runs as-is and emits `mongo_transaction_unsupported` (`deployment: standalone|unknown`) — it never pretends to be atomic. Executors without `withTransaction` also run `fn` as-is and emit a `transaction_not_atomic` feedback event (degradation is allowed, silent pretence is not). A nested same-source transaction opens a savepoint (an inner failure rolls back only that scope); without savepoint primitives it degrades by joining the outer transaction and emits `nested_savepoint_unsupported`.
324
+ - `store.executeRaw(source, sql, params = null, isWrite = null)` runs raw SQL, compiled by the core `rawStmtCompile`. Two styles selected by the `params` type: **positional** (array/null) passes the SQL through as-is with native placeholders (`?` for MySQL / SQLite, `$1..$n` for PostgreSQL); **named** (object) compiles `:name` tokens in the SQL into dialect placeholders (same-name reuse, `::` casts / quotes / comments kept intact; missing or unused names throw `RawSqlError`). SQL sources only — a Mongo source throws `RawSqlError` (`store.RawSqlError`).
325
+ - When `isWrite` is omitted it is inferred from the SQL's first word (SELECT / WITH / EXPLAIN / SHOW / PRAGMA / TABLE count as reads, everything else as a write — defaulting to write is the safe direction); passing it explicitly overrides the inference. Returns `{ rows, affectedRows }`: rows for reads, the affected-row count for writes.
326
+ - `store.executeNative(source, collection, pipeline = [], options = null)` runs a native aggregation pipeline on a Mongo source (the Mongo counterpart of the SQL-side `executeRaw` escape hatch): `pipeline` is a native aggregation pipeline, `options` uses driver-native keys (`allowDiskUse` / `batchSize` / `hint` / `maxTimeMS`..., no host-side whitelist). Inside a transaction / session the session is injected automatically (owned by the transaction; `options.session` cannot override it); resolution always follows the read path, so `$merge` / `$out` write stages require you to open a transaction yourself. Mongo sources only — a SQL source throws `NativeCommandError` pointing to `executeRaw`; the MongoClient form requires a schema-declared namespace. Returns `{ rows }`.
327
+
328
+ ### Session (Unit of Work)
329
+
330
+ ```js
331
+ await store.session(async (s) => {
332
+ await s.insert('Order', { ... });
333
+ await s.update('Account', cond, { ... });
334
+ await s.executeRaw('pg_main', 'SELECT ... FOR UPDATE', [1]);
335
+ });
336
+ ```
337
+
338
+ - Inside a session, **every command on the same SQL source lands on one transaction connection**: the session commits once on exit, and rolls back as one unit on any exception.
339
+ - **Lazy transaction start**: a session with no commands never checks out a connection.
340
+ - **Cross-source writes fail closed**: if a session writes to ≥2 datasources, it rolls everything back and throws `NonAtomicWriteError` on exit (no distributed transaction — it never commits a half-done unit of work).
341
+ - Mongo sources are probed at runtime (replica set / sharded) and made transactional; on standalone or probe failure they run as-is (non-atomic) and emit one `mongo_transaction_unsupported` (`deployment: standalone|unknown`) feedback event.
342
+ - Sessions nest: an inner scope opens a savepoint (`SAVEPOINT sp_<n>`) on the outer transaction and, on exit, `RELEASE`s it (success) or `ROLLBACK TO`s and releases it (failure) — **an inner failure rolls back only the inner scope while the outer one continues**. When the transaction handle has no savepoint primitives, the nested scope degrades by joining the outer one and emits one `nested_savepoint_unsupported` feedback event.
322
343
 
323
344
  ### DDL generation
324
345
 
@@ -530,11 +551,32 @@ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) }
530
551
 
531
552
  ## Transaction boundary
532
553
 
533
- - **Single SQL source**: `mutation` parent-child step sequences and `remove` (archive + delete) run inside one driver transaction on one checked-out connection — any step failure rolls back the whole sequence.
534
- - **Each SQL write command** is itself atomic: multi-statement plans (e.g. MySQL write + readback) are transaction-wrapped in the executor.
535
- - **Mongo sources**: single-document writes are atomic; multi-step `mutation` and `remove` execute sequentially and are **not** atomic across steps (Mongo transactions require a replica set). If your consistency requirement spans steps on Mongo, either use an SQL source for those models or add application-level compensation.
554
+ | Scenario | Atomicity |
555
+ |---|---|
556
+ | Single-command API (`insert` / `insertMany` / `updateMany` / `upsert` / `remove` / `count` / `exists`) | Naturally atomic within one SQL source (a single statement); single documents are atomic on Mongo |
557
+ | `store.transaction(source, fn)` | Atomic within one SQL source: every command in the scope shares one connection and one transaction; nested same-source scopes use a savepoint (an inner failure rolls back only that scope) |
558
+ | `store.session(...)` | Atomic **across multiple calls** on one SQL source inside the session; cross-source writes are rejected explicitly (`NonAtomicWriteError`) |
559
+ | Cross-source multi-write without a session | Not atomic (no 2PC / Saga), executed datasource by datasource, and declares `nonAtomic` via the feedback channel (event `non_atomic_write`, with the sources) |
560
+ | Multi-step writes on Mongo | replica set / sharded: atomic on a single Mongo source (session transaction); standalone: non-atomic and explicitly declares `mongo_transaction_unsupported` |
561
+
562
+ - **Mongo sources**: made transactional inside a session according to the runtime probe; non-transactable ones (standalone / probe failure) run as-is and emit a `mongo_transaction_unsupported` feedback event (`deployment: standalone|unknown`) (degradation is allowed, silent pretence is not).
563
+ - **SQL executors without `openTransaction`**: commands run as-is inside a session and emit a `session_not_atomic` feedback event (degradation is allowed, silent pretence is not).
564
+ - **SQL executors without `withTransaction`**: commands run as-is inside a transaction scope (or the top-level atomic envelope) and emit a `transaction_not_atomic` feedback event (symmetric with `session_not_atomic`; degradation is allowed, silent pretence is not).
536
565
  - **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
537
- - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
566
+ - Read consistency: only multiple reads inside an explicit session share one transaction connection; reads outside a session do not open an extra transaction.
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).
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.
538
580
 
539
581
  ## FAQ
540
582
 
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
 
@@ -315,10 +319,29 @@ async function transfer() {
315
319
  await store.transaction('default', transfer);
316
320
  ```
317
321
 
318
- - `store.transaction(source, fn)` 在单个 SQL 源上开启事务作用域:`fn` 内的每个 `executeRaw` / CRUD 调用都落到该源的事务连接,`commit` / `rollback` 作为一个整体(复用内部的 `runInTransaction`)。Mongo 源或不支持事务的执行器会按原样执行 `fn` —— 绝不假装已原子。
322
+ - `store.transaction(source, fn)` 在单个 SQL 源上开启事务作用域:`fn` 内的每个 `executeRaw` / CRUD 调用都落到该源的事务连接,`commit` / `rollback` 作为一个整体(复用内部的 `runInTransaction`)。Mongo 源按**运行时能力探测**(replica set / sharded)以 session 事务执行;standalone 或探测失败则按原样执行 `fn` 并发 `mongo_transaction_unsupported`(`deployment: standalone|unknown`)—— 绝不假装已原子。不支持事务(未实现 `withTransaction`)的执行器亦按原样执行,并发出一条 `transaction_not_atomic` 反馈(允许降级,绝不静默假装已事务化)。同源嵌套 transaction 会开保存点(内层失败只回滚本层);句柄无保存点原语时降级并入外层并发 `nested_savepoint_unsupported`。
319
323
  - `store.executeRaw(source, sql, params, isWrite)` 执行原生 SQL,绕开 GQL 解析与方言翻译。占位符沿用各后端原生风格:MySQL / SQLite 用 `?`,PostgreSQL 用 `$1..$n`。仅限 SQL 源 —— Mongo 源会抛出 `RawSqlError`(`store.RawSqlError`)。
320
324
  - `isWrite=false`(默认)返回 `{ rows, affectedRows }` 含结果集行;`isWrite=true` 返回影响行数。
321
325
 
326
+ ### 会话(Session / 工作单元)
327
+
328
+ ```js
329
+ await store.session(async (s) => {
330
+ await s.insert('Order', { ... });
331
+ await s.update('Account', cond, { ... });
332
+ await s.executeRaw('pg_main', 'SELECT ... FOR UPDATE', [1]);
333
+ });
334
+ ```
335
+
336
+ - 会话内同一 SQL 源的**全部命令落到同一事务连接**:退出统一提交,异常统一回滚;
337
+ - **惰性开事务**:会话内没有任何命令时不占用连接;
338
+ - **跨源写 fail-closed**:同一会话内写入了 ≥2 个数据源时,退出先全部回滚再抛
339
+ `NonAtomicWriteError`(跨源无分布式事务,绝不提交半截);
340
+ - Mongo 源按**运行时能力探测**(replica set / sharded)事务化;standalone 或探测失败则按原样执行(非原子),并发出一条 `mongo_transaction_unsupported`(`deployment: standalone|unknown`)反馈;
341
+ - 会话可嵌套:内层作用域在已有事务上开保存点(`SAVEPOINT sp_<n>`),退出按成败
342
+ `RELEASE`(成功)/ `ROLLBACK TO` + `RELEASE`(失败)——**内层失败只回滚内层,外层可继续**;
343
+ 事务句柄未提供保存点原语时降级并入外层,并发出一条 `nested_savepoint_unsupported` 反馈。
344
+
322
345
  ### DDL 生成
323
346
 
324
347
  ```js
@@ -527,11 +550,36 @@ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) }
527
550
 
528
551
  ## 事务边界
529
552
 
530
- - **单一 SQL 源**:`mutation` 的父子步骤序列与 `remove`(归档 + 删除)在一个已检出的连接上的单个驱动事务内执行 —— 任一步失败会回滚整个序列。
531
- - **每条 SQL 写入命令**本身即原子:多语句计划(例如 MySQL 的写入 + 回读)在执行器内被事务包裹。
532
- - **Mongo 源**:单文档写入是原子的;多步 `mutation` 与 `remove` 顺序执行,跨步骤**不**原子(Mongo 事务需要副本集)。若你的跨步骤一致性要求发生在 Mongo 上,请为这些模型改用 SQL 源,或补充应用层补偿。
553
+ | 场景 | 原子性 |
554
+ |---|---|
555
+ | 单命令 API(`insert` / `insertMany` / `updateMany` / `upsert` / `remove` / `count` / `exists`) | 单 SQL 源内天然原子(单条 SQL);Mongo 单文档原子 |
556
+ | `store.transaction(source, fn)` | 单 SQL 源内原子:作用域内所有命令同连接、同事务;同源嵌套开保存点(内层失败只回滚本层) |
557
+ | `store.session(...)` | 会话内单 SQL 源**跨多次调用**原子;跨源写被显式拦截(`NonAtomicWriteError`) |
558
+ | 无会话的跨源多写 | 非原子(无 2PC / Saga 支持),按数据源顺序执行,并经反馈通道声明 `nonAtomic`(事件 `non_atomic_write`,含涉及源) |
559
+ | Mongo 多步写 | replica set / sharded:单 Mongo 源原子(session 事务);standalone:非原子并显式声明 `mongo_transaction_unsupported` |
560
+
561
+ - **Mongo 源**:会话内按运行时能力探测结果事务化;不可事务(standalone / 探测失败)按原样执行,
562
+ 并发出 `mongo_transaction_unsupported` 反馈(`deployment: standalone|unknown`)(允许降级,绝不静默假装已事务化);
563
+ - **未实现 `openTransaction` 的 SQL 执行器**:会话内按原样执行,并发出
564
+ `session_not_atomic` 反馈(允许降级,绝不静默假装已事务化);
565
+ - **未实现 `withTransaction` 的 SQL 执行器**:`store.transaction` / 顶层原子包络内按原样执行,
566
+ 并发出 `transaction_not_atomic` 反馈(与 `session_not_atomic` 对称,允许降级,绝不静默假装已事务化);
533
567
  - **归档幂等**:`remove` 的归档采用按 `_id` upsert 的语义,因此部分失败后的重试不会再因 `_id` 重复而失败。
534
- - **跨源步骤**(父与子绑定到不同数据源)无法原子 —— 按设计顺序执行。
568
+ - 读一致性:只有在显式会话内的多条读才共享同一事务连接;会话外读不额外开启事务。
569
+ - **跨源写(非会话)**:一次写调用涉及 ≥2 个数据源时**无法原子**,按顺序执行,并发出一条
570
+ `non_atomic_write` 反馈(`code: nonAtomic`,含涉及源列表)——允许降级、禁止静默。
571
+ 把写收敛到单源,或放入 `store.session()` 内(后者对跨源写直接 fail-closed)。
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 三方言逐字节一致。
535
583
 
536
584
  ## 常见问题
537
585
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-store",
3
- "version": "2.4.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/exec.js CHANGED
@@ -113,9 +113,14 @@ function _call(fn) {
113
113
  // ─── 命令执行(唯一 IO 边界) ────────────────────────────────
114
114
 
115
115
  /** 在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec;
116
- * 事务作用域内经 datasource.connectionFor 落到事务专用连接) */
116
+ * 事务 / 会话作用域内经 datasource.resolveConnection 落到事务专用连接) */
117
117
  async function _execOn(source, cmd) {
118
- const connection = datasource.connectionFor(source);
118
+ const connection = await datasource.resolveConnection(source, datasource.isWriteCommand(cmd));
119
+ if (connection && connection.kind === 'mongo') {
120
+ // Mongo 事务视图:db 按命令 namespace 解析,session 透传给驱动
121
+ const db = datasource.mongoDb(connection.conn, source, cmd.namespace ?? null);
122
+ return execMongo(db, cmd, connection.session);
123
+ }
119
124
  const db = datasource.mongoDb(connection, source, cmd.namespace ?? null);
120
125
  if (db) {
121
126
  return execMongo(db, cmd);
@@ -128,6 +133,64 @@ async function _exec(cmd) {
128
133
  return _execOn(cmd.source || datasource.DEFAULT_SOURCE, cmd);
129
134
  }
130
135
 
136
+ /** 从规划结果中提取数据源集合(探针 / 写 / 查 / 删 / mutation 步骤命令)
137
+ *
138
+ * `sources` 必须由规划结果提取,不得写死 `default`(多租户路由场景下的
139
+ * 源由 core 规划决定)。
140
+ */
141
+ function sourcesOf(plan) {
142
+ const out = new Set();
143
+ for (const key of ['needsProbe', 'command', 'findCommand', 'deleteCommand']) {
144
+ const cmd = (plan || {})[key] || {};
145
+ if (cmd && Object.keys(cmd).length > 0) out.add(cmd.source || datasource.DEFAULT_SOURCE);
146
+ }
147
+ for (const step of (plan || {}).steps || []) {
148
+ const cmd = (step || {}).command || {};
149
+ if (cmd && Object.keys(cmd).length > 0) out.add(cmd.source || datasource.DEFAULT_SOURCE);
150
+ }
151
+ return out.size > 0 ? out : new Set([datasource.DEFAULT_SOURCE]);
152
+ }
153
+
154
+ /** 多源写:无法原子 → 程序化声明 nonAtomic(允许顺序执行,禁止静默) */
155
+ function warnMultiSource(sources) {
156
+ const listed = Array.from(sources).sort();
157
+ _emitFeedback({
158
+ type: 'non_atomic_write',
159
+ code: 'nonAtomic',
160
+ layer: 'crud',
161
+ message: `本次写调用跨 ${listed.length} 个数据源(${listed.join(', ')}):无法原子,按顺序执行(非原子)`,
162
+ hint: '把写操作收敛到单源;或在 store.session() 内执行以便跨源写被拒(fail-closed)',
163
+ sources: listed,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * 顶层 API 调用的原子包络:无会话 + 单一源(SQL 或 Mongo)→ 包事务;否则原样执行
169
+ *
170
+ * - 会话内:事务边界由会话统一管理,直接执行(不嵌套);
171
+ * - 单一 SQL 源:包事务(原子);
172
+ * - 多源:无法原子 → 程序化声明 `nonAtomic`(反馈通道),再按顺序原样执行;
173
+ * - 单一 Mongo 源:按探测结果包 session 事务或降级声明(见 `runInTransaction`);
174
+ * - 未配置源:按原样执行;
175
+ * - sources 由调用方从「规划结果」中提取(`sourcesOf`),命令源与事务源一致。
176
+ */
177
+ async function runAtomic(sources, fn) {
178
+ if (datasource.currentSession() !== null) return fn();
179
+ const uniq = new Set(Array.from(sources, (s) => s || datasource.DEFAULT_SOURCE));
180
+ if (uniq.size === 1) {
181
+ const [source] = uniq;
182
+ if (
183
+ datasource.hasConnection(source) &&
184
+ (datasource.isSql(source) || datasource.isMongoSource(source))
185
+ ) {
186
+ return datasource.runInTransaction(source, fn);
187
+ }
188
+ } else if (uniq.size > 1) {
189
+ warnMultiSource(uniq);
190
+ }
191
+ return fn();
192
+ }
193
+
131
194
  /** 深度替换命令中的占位符(命中 resolver 返回非字符串时替换) */
132
195
  function _substitute(value, resolver) {
133
196
  if (typeof value === 'string') return resolver(value);
@@ -172,6 +235,9 @@ module.exports = {
172
235
  _PROFILE_HINT,
173
236
  _exec,
174
237
  _execOn,
238
+ runAtomic,
239
+ warnMultiSource,
240
+ sourcesOf,
175
241
  _substitute,
176
242
  resolvePlaceholders,
177
243
  };
@@ -5,9 +5,8 @@
5
5
  */
6
6
 
7
7
  const { core: _core, get: _getSchema } = require('../schema');
8
- const datasource = require('../datasource');
9
8
  const { emit: _emitFeedback } = require('../feedback');
10
- const { _call, _ctx, _exec, _nowFor, resolvePlaceholders } = require('./exec');
9
+ const { _call, _ctx, _exec, _nowFor, resolvePlaceholders, runAtomic, sourcesOf } = require('./exec');
11
10
  const { _generateId, _newIdPool } = require('./id');
12
11
 
13
12
  /** mutation 单条:规划步骤序列 → 依序执行 + 父子 _id 占位符回填 */
@@ -36,11 +35,8 @@ async function _mutationOne(schemaName, data, now, routeOverride = null) {
36
35
 
37
36
  // 单一 SQL 源 → 步骤序列整体事务化(同连接同事务,任一步失败整体回滚);
38
37
  // Mongo 源 / 跨源步骤按原样顺序执行(非原子边界见 README「事务边界」)
39
- const sources = [...new Set(plan.steps.map((s) => s.command.source || datasource.DEFAULT_SOURCE))];
40
- if (sources.length === 1 && datasource.isSql(sources[0])) {
41
- return datasource.runInTransaction(sources[0], runSteps);
42
- }
43
- return runSteps();
38
+ const sources = sourcesOf(plan);
39
+ return runAtomic(sources, runSteps);
44
40
  }
45
41
 
46
42
  /**
package/src/crud/write.js CHANGED
@@ -5,8 +5,7 @@
5
5
  */
6
6
 
7
7
  const { core: _core, get: _getSchema } = require('../schema');
8
- const datasource = require('../datasource');
9
- const { _call, _ctx, _exec, _nowFor } = require('./exec');
8
+ const { _call, _ctx, _exec, _nowFor, runAtomic, sourcesOf } = require('./exec');
10
9
  const { _generateId } = require('./id');
11
10
 
12
11
  /** creator 写权限探针:先规划,若 needsProbe 则执行探针命令后重入 */
@@ -25,8 +24,14 @@ async function insert(schemaName, data, routeOverride = null) {
25
24
  const plan = _call(() =>
26
25
  _core.planInsert(schemaName, data ?? null, _nowFor(schemaName), s.idPrefix ? _generateId(s) : '', _ctx(),
27
26
  routeOverride));
28
- await _exec(plan.command);
29
- 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;
30
35
  }
31
36
 
32
37
  /** 批量插入(带权限检查,自动生成 _id 和时间戳;空数组直接返回空) */
@@ -34,6 +39,14 @@ async function insertMany(schemaName, docs, routeOverride = null) {
34
39
  if (!Array.isArray(docs) || !docs.length) return [];
35
40
 
36
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
+ }
37
50
  const plan = _call(() => _core.planInsertMany(
38
51
  schemaName,
39
52
  docs,
@@ -52,20 +65,73 @@ async function insertMany(schemaName, docs, routeOverride = null) {
52
65
  *
53
66
  * data 的 key 以 '$' 开头 → 原生 MongoDB 操作符($set/$inc/$unset 等)直接透传。
54
67
  * 否则自动包装为 $set 模式。`routeOverride` 可选(多租户路由)。
68
+ *
69
+ * 「权限探针 + 写」整体纳入同一原子作用域(`runAtomic`):单一 SQL 源时探针与写
70
+ * 同连接同事务,消除二者之间的并发窗口;`now` 只取一次,两次规划共用(调用级确定性)。
55
71
  */
56
72
  async function update(schemaName, condition, data, options = null, routeOverride = null) {
57
- const out = await _planWithProbe((found, doc) => _call(() =>
58
- _core.planUpdate(schemaName, condition ?? null, data ?? null, options ?? null, _nowFor(schemaName), _ctx(),
59
- found, doc, routeOverride)));
60
- const result = await _exec(out.command);
61
- return result ? _call(() => _core.applyWriteDefaults(schemaName, result)) : null;
73
+ const now = _nowFor(schemaName);
74
+ const ctx = _ctx();
75
+ const first = _call(() =>
76
+ _core.planUpdate(schemaName, condition ?? null, data ?? null, options ?? null, now, ctx,
77
+ null, null, routeOverride));
78
+ const sources = sourcesOf(first);
79
+
80
+ const doRun = async () => {
81
+ let out = first;
82
+ if (out.needsProbe) {
83
+ const probeDoc = await _exec(out.needsProbe);
84
+ out = _call(() =>
85
+ _core.planUpdate(schemaName, condition ?? null, data ?? null, options ?? null, now, ctx,
86
+ probeDoc !== null && probeDoc !== undefined, probeDoc ?? null, routeOverride));
87
+ }
88
+ const result = await _exec(out.command);
89
+ return result ? _call(() => _core.applyWriteDefaults(schemaName, result)) : null;
90
+ };
91
+
92
+ return runAtomic(sources, doRun);
93
+ }
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);
62
128
  }
63
129
 
64
130
  /** 批量更新(支持原生操作符) */
65
131
  async function updateMany(schemaName, condition, data, routeOverride = null) {
66
132
  const out = _call(() =>
67
133
  _core.planUpdateMany(schemaName, condition ?? null, data ?? null, _nowFor(schemaName), _ctx(), routeOverride));
68
- const result = await _exec(out.command);
134
+ const result = await execWithPre(out.command);
69
135
  return { modifiedCount: result.modifiedCount };
70
136
  }
71
137
 
@@ -78,8 +144,15 @@ async function remove(schemaName, condition, routeOverride = null) {
78
144
 
79
145
  const doRemove = async () => {
80
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
+ }
81
154
  if (out.findCommand) {
82
- const docs = await _exec(out.findCommand);
155
+ const docs = await (ids !== null ? fillPreIds(out.findCommand, ids) : _exec(out.findCommand));
83
156
  if (docs.length) {
84
157
  const arch = _call(() => _core.planArchiveDocs(schemaName, docs, _nowFor(schemaName), routeOverride));
85
158
  await _exec(arch.command);
@@ -87,17 +160,12 @@ async function remove(schemaName, condition, routeOverride = null) {
87
160
  }
88
161
  }
89
162
 
90
- const result = await _exec(out.deleteCommand);
163
+ const result = await (ids !== null ? fillPreIds(out.deleteCommand, ids) : _exec(out.deleteCommand));
91
164
  return { deletedCount: result.deletedCount, archivedCount };
92
165
  };
93
166
 
94
- const sources = new Set([out.deleteCommand.source || datasource.DEFAULT_SOURCE]);
95
- if (out.findCommand) sources.add(out.findCommand.source || datasource.DEFAULT_SOURCE);
96
- const arr = [...sources];
97
- if (arr.length === 1 && datasource.isSql(arr[0])) {
98
- return datasource.runInTransaction(arr[0], doRemove);
99
- }
100
- return doRemove();
167
+ const sources = sourcesOf(out);
168
+ return runAtomic(sources, doRemove);
101
169
  }
102
170
 
103
171
  /** 判断是否存在 */