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 +49 -7
- package/README.zh-CN.md +53 -5
- package/package.json +1 -1
- package/src/crud/exec.js +68 -2
- package/src/crud/mutation.js +3 -7
- package/src/crud/write.js +87 -19
- package/src/datasource.js +652 -30
- package/src/ddl.js +68 -11
- package/src/executors/index.js +53 -3
- package/src/executors/mongo.js +78 -15
- package/src/executors/mysql.js +63 -24
- package/src/executors/postgres.js +57 -35
- package/src/executors/sqlite.js +56 -20
- package/src/index.js +76 -4
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
|
|
320
|
-
- `store.executeRaw(source, sql, params, isWrite)` runs raw SQL,
|
|
321
|
-
- `isWrite
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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.
|
|
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
|
-
*
|
|
116
|
+
* 事务 / 会话作用域内经 datasource.resolveConnection 落到事务专用连接) */
|
|
117
117
|
async function _execOn(source, cmd) {
|
|
118
|
-
const connection = datasource.
|
|
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
|
};
|
package/src/crud/mutation.js
CHANGED
|
@@ -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 =
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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 =
|
|
95
|
-
|
|
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
|
/** 判断是否存在 */
|