nodejs-store 1.0.0 → 2.0.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
@@ -10,7 +10,7 @@ This is the Node.js port of [`py-store`](https://github.com/coenddt/py-store)
10
10
  | --- | --- |
11
11
  | MongoDB | native aggregation pipeline (`find`/`aggregate`/`$lookup`) |
12
12
  | MySQL | parameterized SQL, `information_schema` introspection |
13
- | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection |
13
+ | SQLite | parameterized SQL, `sqlite_master` + `PRAGMA` introspection. **Sync driver** (`better-sqlite3`): calls block the event loop by design — for high-concurrency hot paths prefer MySQL/PostgreSQL/MongoDB, or isolate SQLite in a dedicated process |
14
14
  | PostgreSQL | parameterized SQL (`$n`), `RETURNING` support |
15
15
 
16
16
  GQL tree queries compile to a single native query per backend — never hand-write `$lookup` or raw SQL again.
@@ -105,6 +105,10 @@ await store.query('User($condition:@c0){...}', params, { namespace: 'tenant_42'
105
105
  await store.insert('Order', data, { source: 'pg_cluster', namespace: 'tenant_7' });
106
106
  ```
107
107
 
108
+ **`routeOverride` is a trusted server-side parameter** — it carries no origin check, so
109
+ forwarding user-controlled input into it lets a caller re-target another tenant's
110
+ `source`/`namespace` (CWE-639 authorization-bypass surface). Never pass raw request data here.
111
+
108
112
  Legacy single-db usage (`init(db)` + schema without `datasource`/`namespace`) is unchanged:
109
113
  commands carry `source: 'default'`, `namespace: null`.
110
114
 
@@ -119,7 +123,11 @@ Model($condition:@c0,$sort:@s1,$skip:@sk,$limit:@l1) {
119
123
 
120
124
  - Values come from the params object: `{ c0: {...}, s1: {...} }`.
121
125
  - Object sub-fields use dot notation; relations are declared in the schema (`type: 'many' | 'one'`) and resolved automatically — **do not hand-write `$lookup`**.
122
- - `$pipeline` passes a raw aggregation through as-is (no compute/defaults/permission trimming) — use with care; prefer `store.aggregate(model, pipeline)` for group/sum needs.
126
+
127
+ > **Breaking change**: user `$pipeline` passthrough and `store.aggregate()` were removed
128
+ > (raw aggregation escape hatch). A GQL containing `$pipeline` now fails explicitly instead
129
+ > of being silently ignored. Use `$condition`/`$sort`/`$skip`/`$limit` + relations; normalized
130
+ > aggregation (`$group`/`$sum`) is being redesigned and will return under a single GQL syntax.
123
131
 
124
132
  ## Query & write API
125
133
 
@@ -138,7 +146,6 @@ await store.updateMany('Post', { type: t }, { status: 'live' });
138
146
  const r = await store.remove('Post', { _id: pid }); // archives to <collection>_deleted first
139
147
  await store.mutation('Post', { ... }); // smart upsert + recursive relation children
140
148
  await store.upsert('Post', { code: 'A1' }, { ... }); // explicit-condition upsert (no relation handling)
141
- const rows = await store.aggregate('Post', pipeline); // native aggregation
142
149
  ```
143
150
 
144
151
  Notes:
@@ -165,6 +172,22 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
165
172
  - No context set → permission checks disabled (backward compatible).
166
173
  - Denied access throws `store.PermissionError` (with `status = 403`).
167
174
 
175
+ ### Fail-secure mode (opt-in)
176
+
177
+ "No context" can mean both *system call* and *caller forgot the context* — by default the
178
+ latter silently passes every check (fail-open, kept for backward compatibility). For
179
+ security-sensitive hosts, enable the context requirement once at startup:
180
+
181
+ ```js
182
+ store.setRequireContext(true);
183
+ // now every query/write without a context throws `ERR_NO_CONTEXT:...`
184
+ // internal jobs must be explicit:
185
+ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
186
+ ```
187
+
188
+ `runAsInternal` marks the call as `{ internal: true }`, which is semantically distinct from
189
+ a missing context and always passes. `setRequireContext(false)` restores the default.
190
+
168
191
  ## Schema reference
169
192
 
170
193
  ```js
@@ -183,7 +206,7 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
183
206
  },
184
207
  computes: {
185
208
  total: { type: 'float', depends: ['amount'], fn: (d) => d.amount * 1.1 },
186
- itemCount: { type: 'int', lookup: { $size: { $ifNull: ['$items', []] } } },
209
+ itemCount: { type: 'int', agg: { $count: 'items' } },
187
210
  },
188
211
  indexes: [
189
212
  { keys: { status: 1 } },
@@ -196,6 +219,94 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
196
219
 
197
220
  Types: `string | int | long | float | double | boolean | array | object | date | any`.
198
221
 
222
+ ## Advanced API
223
+
224
+ Everything below is reachable from the exported `store` singleton or the modules it
225
+ re-exports. Options prefixed with `?` are optional.
226
+
227
+ ### `store.buildPipeline(gql, params?)`
228
+
229
+ Low-level parse — compiles GQL to the command plan **without executing it**, returning
230
+ `{ tokens, ast, pipeline, projection }`. Useful for debugging query shape, asserting
231
+ pushdown behaviour, or building custom tooling. Permissions / computes are **not** applied here.
232
+
233
+ ```js
234
+ const plan = store.buildPipeline('Post($condition:@c0){ title }', { c0: { status: 'draft' } });
235
+ console.log(plan.pipeline);
236
+ ```
237
+
238
+ ### `store.syncSchema(opts)`
239
+
240
+ Pull a SQL backend's physical structure into the registry
241
+ (`introspect → schemaFromRows → mergeSchema(overlay) → register`). It only **reads** the
242
+ structure — it never writes DDL back to the database.
243
+
244
+ | Option | Type | Meaning |
245
+ | --- | --- | --- |
246
+ | `backend` | `'mysql' \| 'postgres' \| 'sqlite'` | required |
247
+ | `driver` | object | required; prefer a read-only account |
248
+ | `introspectOptions` | object | passed through to introspection (e.g. PG `schema`) |
249
+ | `overlay` | `Array` | local schemaJSON merged on top (permissions / computes / overrides) |
250
+ | `datasource` | string | bind every merged def to this source |
251
+ | `namespace` | string | bind every merged def to this namespace |
252
+ | `registerDefs` | boolean (default `true`) | `false` = return defs without registering |
253
+
254
+ Returns the merged `schemaJSON[]`.
255
+
256
+ ```js
257
+ const defs = await store.syncSchema({
258
+ backend: 'postgres', driver: pgPool, overlay: [Post], datasource: 'pg_a',
259
+ });
260
+ ```
261
+
262
+ ### `store.setFeedbackSink(fn)`
263
+
264
+ Take over the unified feedback channel used for fallback / degradation / interception
265
+ events. The sink receives one event object; pass `null` (or a non-function) to fall back to
266
+ the default stderr printer.
267
+
268
+ ```js
269
+ store.setFeedbackSink((e) => logger.warn({ code: e.code }, e.hint));
270
+ // event shape: { type, code, layer, message, hint, ... }
271
+ // type federation_degraded | sql_pushdown_unsupported | ...
272
+ // code crossSourceSort | pushdownUnsupported | ...
273
+ // layer federation | dialect | ...
274
+ ```
275
+
276
+ ### Low-level modules
277
+
278
+ The package re-exports its building blocks for advanced hosts:
279
+
280
+ ```js
281
+ const {
282
+ init, store, Store,
283
+ PermissionError, // thrown on denied access (status = 403)
284
+ PushdownUnsupportedError, // thrown when a command cannot be safely pushed down
285
+ datasource, schema, permission, crud, executors, feedback, introspect,
286
+ syncSchema, // same function as store.syncSchema
287
+ } = require('nodejs-store');
288
+
289
+ // introspect.run(backend, driver, options) → normalized structure rows
290
+ const rows = await introspect.run('mysql', pool, {});
291
+
292
+ // executors.createConnection(kind, driver, options) → SQL datasource descriptor { kind, exec }
293
+ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) });
294
+ ```
295
+
296
+ - `schema` / `permission` / `feedback` / `datasource` expose the same functions the `store`
297
+ singleton delegates to (e.g. `datasource.setConnections`, `datasource.hasConnection`,
298
+ `datasource.isSql`, `datasource.runInTransaction`).
299
+ - **Multi-tenant route override** — pass `{ source, namespace }` as the last argument of any
300
+ query/write, see [Multi-datasource connections](#multi-datasource-connections).
301
+
302
+ ## Transaction boundary
303
+
304
+ - **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.
305
+ - **Each SQL write command** is itself atomic: multi-statement plans (e.g. MySQL write + readback) are transaction-wrapped in the executor.
306
+ - **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.
307
+ - **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
308
+ - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
309
+
199
310
  ## License
200
311
 
201
312
  [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-store",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Lightweight multi-backend data layer (MongoDB / MySQL / SQLite / PostgreSQL): pure JSON schemas, GQL tree queries compiled to a single query, computed columns, soft-delete and role-based access control",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -9,7 +9,9 @@
9
9
  "LICENSE"
10
10
  ],
11
11
  "scripts": {
12
- "test": "node scripts/test.js"
12
+ "test": "node scripts/test.js",
13
+ "test:coverage": "c8 --all --include src/** -x **/rust-store/** --reporter=text --reporter=html --check-coverage --statements 90 --lines 90 --functions 85 --branches 75 node scripts/test.js",
14
+ "lint": "eslint ."
13
15
  },
14
16
  "keywords": [
15
17
  "mongodb",
@@ -41,6 +43,12 @@
41
43
  "mongodb": "^6.21.0",
42
44
  "mysql2": "^3.24.4",
43
45
  "pg": "^8.23.0",
44
- "rust-store-node": "^1.0.0"
46
+ "rust-store-node": "^2.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@eslint/js": "^9.39.2",
50
+ "c8": "^10.1.3",
51
+ "eslint": "^9.39.2",
52
+ "globals": "^15.15.0"
45
53
  }
46
54
  }
package/src/core.js CHANGED
@@ -34,6 +34,12 @@ function _requireDevFallback() {
34
34
  }
35
35
 
36
36
  function _load() {
37
+ // LOCAL_CORE=1(且非 production)优先加载本地调试产物 —— 使「从相邻 rust-store
38
+ // 仓库加载」的语义与文档一致;未设置或加载失败再走 npm 依赖。
39
+ if (process.env.LOCAL_CORE === '1' && process.env.NODE_ENV !== 'production') {
40
+ const local = _requireDevFallback();
41
+ if (local && !local.__error) return local;
42
+ }
37
43
  try {
38
44
  return require('rust-store-node');
39
45
  } catch (e) {
package/src/crud/exec.js CHANGED
@@ -14,76 +14,59 @@
14
14
 
15
15
  const { PermissionError, getContext } = require('../permission');
16
16
  const datasource = require('../datasource');
17
+ const { execMongo } = require('../executors/mongo');
18
+ const { get: _getSchema } = require('../schema');
17
19
 
18
20
  const _PHASE1_IDS = /^\{\{phase1\.ids\}\}$/;
19
21
  const _STEP_PH = /^\{\{step\.(\d+)\._id\}\}$/;
20
22
 
21
- /** core 权限类错误消息 → PermissionError(消息与 core 常量保持一致) */
22
- const _PERMISSION_MSGS = new Set(['无访问权限', '无写入权限', '无删除权限', '无批量写入权限']);
23
+ /**
24
+ * 权限类错误识别:core 权限错误统一携带 `ERR_PERMISSION:` 稳定前缀(见 core
25
+ * `command/mod.rs::ERR_PERM_PREFIX`),按**前缀**映射而非具体文案 —— core 文案
26
+ * 可自由调整,映射不随文案漂移而静默失效。构造 PermissionError 时剥离前缀。
27
+ */
28
+ const _PERM_PREFIX = 'ERR_PERMISSION:';
23
29
 
24
30
  /** 设置数据源连接映射(对 `../datasource` 的路由入口做包内透出) */
25
31
  const setConnections = datasource.setConnections;
26
32
 
27
- /** 毫秒时间戳(Host 时钟源) */
28
- function _now() {
29
- return Date.now();
33
+ /** 单库简写:等价于 `setConnections({ default: db })`(对齐 py_store.crud.exec.set_db) */
34
+ function setDb(db) {
35
+ datasource.setConnections({ [datasource.DEFAULT_SOURCE]: db });
36
+ }
37
+
38
+ /** 按 schema 的 timestamps 单位产出当前时间戳('s' → 秒,其余/未启用 → 毫秒) */
39
+ function _nowFor(schemaName) {
40
+ const unit = _getSchema(schemaName).timestampUnit;
41
+ return unit === 's' ? Math.floor(Date.now() / 1000) : Date.now();
30
42
  }
31
43
 
32
44
  function _ctx() {
33
45
  return getContext() ?? null;
34
46
  }
35
47
 
36
- /** 绑定层调用包装:权限类错误映射为 PermissionError */
48
+ /** 绑定层调用包装:权限类错误(`ERR_PERMISSION:` 前缀)映射为 PermissionError */
37
49
  function _call(fn) {
38
50
  try {
39
51
  return fn();
40
52
  } catch (e) {
41
- if (_PERMISSION_MSGS.has(e && e.message)) throw new PermissionError(e.message);
53
+ const msg = e && e.message;
54
+ if (typeof msg === 'string' && msg.startsWith(_PERM_PREFIX)) {
55
+ throw new PermissionError(msg.slice(_PERM_PREFIX.length));
56
+ }
42
57
  throw e;
43
58
  }
44
59
  }
45
60
 
46
61
  // ─── 命令执行(唯一 IO 边界) ────────────────────────────────
47
62
 
48
- /** Command JSON → MongoDB 原生驱动调用 */
49
- async function _execMongo(db, cmd) {
50
- const coll = db.collection(cmd.collection);
51
- switch (cmd.kind) {
52
- case 'find': {
53
- const opts = cmd.projection ? { projection: cmd.projection } : undefined;
54
- return coll.find(cmd.filter, opts).toArray();
55
- }
56
- case 'aggregate':
57
- return coll.aggregate(cmd.pipeline).toArray();
58
- case 'countDocuments':
59
- return coll.countDocuments(cmd.filter);
60
- case 'findOne': {
61
- const opts = cmd.projection ? { projection: cmd.projection } : undefined;
62
- return coll.findOne(cmd.filter, opts);
63
- }
64
- case 'insertOne':
65
- await coll.insertOne(cmd.doc);
66
- return cmd.doc;
67
- case 'insertMany':
68
- await coll.insertMany(cmd.docs);
69
- return { insertedCount: cmd.docs.length };
70
- case 'findOneAndUpdate':
71
- return coll.findOneAndUpdate(cmd.filter, cmd.update, cmd.options);
72
- case 'updateMany':
73
- return coll.updateMany(cmd.filter, cmd.update);
74
- case 'deleteMany':
75
- return coll.deleteMany(cmd.filter);
76
- default:
77
- throw new Error(`未支持的命令: ${cmd.kind}`);
78
- }
79
- }
80
-
81
- /** 在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec) */
63
+ /** 在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec;
64
+ * 事务作用域内经 datasource.connectionFor 落到事务专用连接) */
82
65
  async function _execOn(source, cmd) {
83
- const connection = datasource.getConnection(source);
66
+ const connection = datasource.connectionFor(source);
84
67
  const db = datasource.mongoDb(connection, source, cmd.namespace ?? null);
85
68
  if (db) {
86
- return _execMongo(db, cmd);
69
+ return execMongo(db, cmd);
87
70
  }
88
71
  return datasource.execSql(source, connection, cmd);
89
72
  }
@@ -129,7 +112,8 @@ function resolvePlaceholders(command, { ids = null, steps = [] } = {}) {
129
112
 
130
113
  module.exports = {
131
114
  setConnections,
132
- _now,
115
+ setDb,
116
+ _nowFor,
133
117
  _ctx,
134
118
  _call,
135
119
  _exec,
package/src/crud/id.js CHANGED
@@ -4,18 +4,23 @@
4
4
  * ID 供给(Host 随机源) —— 与 core `needs_new_id` 语义对齐
5
5
  *
6
6
  * core 无随机源:需要新 _id 时由 Host 按序供给,本模块负责生成与遍历。
7
+ * 随机段使用 crypto 强随机源 8 位 base36(约 41 bit 熵):Math.random 仅 4 位
8
+ * (36^4 ≈ 168 万组合),insertMany 同毫秒批量生成时碰撞概率不可忽略(CWE-338)。
7
9
  */
8
10
 
11
+ const crypto = require('node:crypto');
12
+
9
13
  const { get: _getSchema } = require('../schema');
10
14
 
11
15
  const _ID_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789';
12
16
 
13
- /** 按 schema.idPrefix 生成唯一 ID(时间戳36进制 + 随机4位) */
17
+ /** 按 schema.idPrefix 生成唯一 ID(时间戳36进制 + crypto 随机8位) */
14
18
  function _generateId(schema) {
15
19
  const ts = Date.now().toString(36).toUpperCase();
16
20
  let rnd = '';
17
- for (let i = 0; i < 4; i++) {
18
- rnd += _ID_CHARS[Math.floor(Math.random() * _ID_CHARS.length)];
21
+ for (let i = 0; i < 8; i++) {
22
+ // crypto.randomInt 内部拒绝采样,无取模偏差
23
+ rnd += _ID_CHARS[crypto.randomInt(_ID_CHARS.length)];
19
24
  }
20
25
  return schema.idPrefix + ts + rnd.toUpperCase();
21
26
  }
package/src/crud/index.js CHANGED
@@ -16,17 +16,18 @@
16
16
  * - [`id`]:ID 生成与 mutation ID 池遍历
17
17
  * - [`query`]:读路径
18
18
  * - [`write`]:写路径
19
- * - [`mutation`]:mutation / upsert / 原生聚合
19
+ * - [`mutation`]:mutation / upsert
20
20
  */
21
21
 
22
- const { setConnections, _now, _ctx, _call, _exec, _substitute, resolvePlaceholders } = require('./exec');
22
+ const { setConnections, setDb, _nowFor, _ctx, _call, _exec, _substitute, resolvePlaceholders } = require('./exec');
23
23
  const { _generateId, _truthy, _newIdPool } = require('./id');
24
24
  const { query, queryOne, queryWithCount, queryFederated } = require('./query');
25
25
  const { insert, insertMany, update, updateMany, remove, exists, count } = require('./write');
26
- const { mutation, upsert, aggregate } = require('./mutation');
26
+ const { mutation, upsert } = require('./mutation');
27
27
 
28
28
  module.exports = {
29
29
  setConnections,
30
+ setDb,
30
31
  query,
31
32
  queryOne,
32
33
  queryWithCount,
@@ -40,7 +41,6 @@ module.exports = {
40
41
  count,
41
42
  mutation,
42
43
  upsert,
43
- aggregate,
44
44
  // ── Host 契约件(供跨语言同构契约测试与高级用法;下划线表示内部语义) ──
45
45
  _substitute,
46
46
  resolvePlaceholders,
@@ -48,7 +48,7 @@ module.exports = {
48
48
  _truthy,
49
49
  _newIdPool,
50
50
  // ── 内部工具(包内共享) ──
51
- _now,
51
+ _nowFor,
52
52
  _ctx,
53
53
  _call,
54
54
  _exec,
@@ -5,25 +5,42 @@
5
5
  */
6
6
 
7
7
  const { core: _core, get: _getSchema } = require('../schema');
8
- const { _call, _ctx, _exec, _now, resolvePlaceholders } = require('./exec');
8
+ const datasource = require('../datasource');
9
+ const { emit: _emitFeedback } = require('../feedback');
10
+ const { _call, _ctx, _exec, _nowFor, resolvePlaceholders } = require('./exec');
9
11
  const { _generateId, _newIdPool } = require('./id');
10
12
 
11
13
  /** mutation 单条:规划步骤序列 → 依序执行 + 父子 _id 占位符回填 */
12
- async function _mutationOne(schemaName, data, routeOverride = null) {
14
+ async function _mutationOne(schemaName, data, now, routeOverride = null) {
13
15
  const plan = _call(() =>
14
- _core.planMutation(schemaName, data, _now(), _newIdPool(schemaName, data), _ctx(),
16
+ _core.planMutation(schemaName, data, now, _newIdPool(schemaName, data), _ctx(),
15
17
  routeOverride));
16
18
 
17
- const resolved = [];
18
- let rootResult = null;
19
- for (const [i, step] of plan.steps.entries()) {
20
- const cmd = resolvePlaceholders(step.command, { steps: resolved });
21
- const result = await _exec(cmd);
22
- resolved.push(result ? (result._id ?? null) : null);
23
- if (i === 0) rootResult = result; // 首步即根写入
19
+ // §11.4 静默点收口:规划期降级(如关系不可读被跳过)走统一反馈通道,禁止静默失守
20
+ for (const d of plan.degraded || []) {
21
+ _emitFeedback({ ...(d || {}), type: 'mutation_degraded' });
24
22
  }
25
23
 
26
- return rootResult ? _call(() => _core.applyWriteDefaults(schemaName, rootResult)) : null;
24
+ const runSteps = async () => {
25
+ const resolved = [];
26
+ let rootResult = null;
27
+ for (const [i, step] of plan.steps.entries()) {
28
+ const cmd = resolvePlaceholders(step.command, { steps: resolved });
29
+ const result = await _exec(cmd);
30
+ resolved.push(result ? (result._id ?? null) : null);
31
+ if (i === 0) rootResult = result; // 首步即根写入
32
+ }
33
+
34
+ return rootResult ? _call(() => _core.applyWriteDefaults(schemaName, rootResult)) : null;
35
+ };
36
+
37
+ // 单一 SQL 源 → 步骤序列整体事务化(同连接同事务,任一步失败整体回滚);
38
+ // 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();
27
44
  }
28
45
 
29
46
  /**
@@ -38,9 +55,13 @@ async function mutation(schemaName, data, routeOverride = null) {
38
55
 
39
56
  if (!items.length) return isArray ? [] : null;
40
57
 
58
+ // §11.3 确定性输入:一次 mutation 调用共用一个 now
59
+ // (数组内多条 + 父子步骤 + 默认值 / 计算列全部同值)
60
+ const now = _nowFor(schemaName);
61
+
41
62
  const results = [];
42
63
  for (const item of items) {
43
- results.push(await _mutationOne(schemaName, item, routeOverride));
64
+ results.push(await _mutationOne(schemaName, item, now, routeOverride));
44
65
  }
45
66
 
46
67
  return isArray ? results : results[0];
@@ -54,19 +75,11 @@ async function mutation(schemaName, data, routeOverride = null) {
54
75
  async function upsert(schemaName, condition, data, options = null, routeOverride = null) {
55
76
  const s = _getSchema(schemaName);
56
77
  const plan = _call(() => _core.planUpsert(
57
- schemaName, condition ?? null, data ?? null, options ?? null, _now(),
78
+ schemaName, condition ?? null, data ?? null, options ?? null, _nowFor(schemaName),
58
79
  s.idPrefix ? _generateId(s) : '', _ctx(), routeOverride,
59
80
  ));
60
81
  const result = await _exec(plan.command);
61
82
  return result ? _call(() => _core.applyWriteDefaults(schemaName, result)) : null;
62
83
  }
63
84
 
64
- // ─── 原生聚合 ────────────────────────────────────────────────
65
-
66
- /** 对指定 schema 执行 MongoDB 原生聚合查询 */
67
- async function aggregate(schemaName, pipeline, routeOverride = null) {
68
- const cmd = _call(() => _core.planAggregate(schemaName, pipeline ?? [], routeOverride));
69
- return _exec(cmd);
70
- }
71
-
72
- module.exports = { mutation, upsert, aggregate };
85
+ module.exports = { mutation, upsert };
package/src/crud/query.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  const { core: _core, getAsyncFn } = require('../schema');
9
+ const { emit: _emitFeedback } = require('../feedback');
9
10
  const { _call, _ctx, _exec, _execOn, resolvePlaceholders } = require('./exec');
10
11
 
11
12
  /** 执行读命令序列:find 快路径 / 两阶段(取 ID → 关联 → 还原排序)/ 标准聚合 */
@@ -37,8 +38,7 @@ async function _finalize(plan, items) {
37
38
  * GQL 查询(返回数组)
38
39
  *
39
40
  * 支持的 params 键(通过 GQL 的 @key 引用):
40
- * $condition / $sort / $skip / $limit / $pipeline
41
- * 使用 $pipeline 时,框架不追加 compute 层、不补默认值、不裁剪,完全由用户控制。
41
+ * $condition / $sort / $skip / $limit
42
42
  *
43
43
  * `routeOverride`(多租户路由,可选):`{ source?, namespace? }`,覆盖命令定位,
44
44
  * 权限/计算列仍按结构 schema 判定(见 multi-datasource-routing-plan.md §6)。
@@ -48,9 +48,10 @@ async function query(gql, params = null, routeOverride = null) {
48
48
  return _finalize(plan, await _runQueryPlan(plan));
49
49
  }
50
50
 
51
- /** GQL 查询(返回单条) */
51
+ /** GQL 查询(返回单条)—— 走 core `planQueryOne`:未显式 `$limit` 时下推 `$limit(1)` */
52
52
  async function queryOne(gql, params = null, routeOverride = null) {
53
- const items = await query(gql, params, routeOverride);
53
+ const plan = _call(() => _core.planQueryOne(gql, params ?? {}, _ctx(), routeOverride));
54
+ const items = await _finalize(plan, await _runQueryPlan(plan));
54
55
  return items.length ? items[0] : null;
55
56
  }
56
57
 
@@ -75,8 +76,8 @@ async function _runFederatedUnit(unit) {
75
76
  /**
76
77
  * 跨库联邦查询(返回嵌套文档数组)
77
78
  *
78
- * Host 四步:core `planFederated` 拆源 → 逐源执行 → core `mergeFederated`
79
- * 内存 hash join → 统一后处理(`_finalize`,与单库同一路径)。
79
+ * Host 四步:core `planFederated` 拆源 → 并行逐源执行(任一源失败整体失败)
80
+ * → core `mergeFederated` 内存 hash join → 统一后处理(`_finalize`,与单库同一路径)。
80
81
  *
81
82
  * `postprocess` 取自根单元快照(含全部关系),因此结果形状与单库 `query` 完全一致。
82
83
  * 每源取数上限 `MAX_FEDERATION_ROWS` 由 core 强制(超限即报错,拒绝静默全表拉取);
@@ -86,13 +87,11 @@ async function queryFederated(gql, params = null) {
86
87
  const plan = _call(() => _core.planFederated(gql, params ?? {}, _ctx()));
87
88
 
88
89
  for (const d of plan.degraded || []) {
89
- console.warn(`[federation] 降级 ${(d && d.code) || ''}: ${(d && d.message) || ''}`);
90
+ // 降级事件走统一反馈通道(无 sink 时打 stderr,允许拦截,禁止静默失守)
91
+ _emitFeedback({ ...(d || {}), type: 'federation_degraded' });
90
92
  }
91
93
 
92
- const results = [];
93
- for (const unit of plan.sources || []) {
94
- results.push(await _runFederatedUnit(unit));
95
- }
94
+ const results = await Promise.all((plan.sources || []).map(_runFederatedUnit));
96
95
 
97
96
  const merged = _call(() => _core.mergeFederated(plan, results));
98
97
  return _finalize(plan, merged);
package/src/crud/write.js CHANGED
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  const { core: _core, get: _getSchema } = require('../schema');
8
- const { _call, _ctx, _exec, _now } = require('./exec');
8
+ const datasource = require('../datasource');
9
+ const { _call, _ctx, _exec, _nowFor } = require('./exec');
9
10
  const { _generateId } = require('./id');
10
11
 
11
12
  /** creator 写权限探针:先规划,若 needsProbe 则执行探针命令后重入 */
@@ -22,7 +23,7 @@ async function _planWithProbe(planFn) {
22
23
  async function insert(schemaName, data, routeOverride = null) {
23
24
  const s = _getSchema(schemaName);
24
25
  const plan = _call(() =>
25
- _core.planInsert(schemaName, data ?? null, _now(), s.idPrefix ? _generateId(s) : '', _ctx(),
26
+ _core.planInsert(schemaName, data ?? null, _nowFor(schemaName), s.idPrefix ? _generateId(s) : '', _ctx(),
26
27
  routeOverride));
27
28
  await _exec(plan.command);
28
29
  return plan.returns;
@@ -36,7 +37,7 @@ async function insertMany(schemaName, docs, routeOverride = null) {
36
37
  const plan = _call(() => _core.planInsertMany(
37
38
  schemaName,
38
39
  docs,
39
- _now(),
40
+ _nowFor(schemaName),
40
41
  // core 按需消费(仅无 _id 的文档取用),多备无害
41
42
  docs.map(() => (s.idPrefix ? _generateId(s) : '')),
42
43
  _ctx(),
@@ -54,7 +55,7 @@ async function insertMany(schemaName, docs, routeOverride = null) {
54
55
  */
55
56
  async function update(schemaName, condition, data, options = null, routeOverride = null) {
56
57
  const out = await _planWithProbe((found, doc) => _call(() =>
57
- _core.planUpdate(schemaName, condition ?? null, data ?? null, options ?? null, _now(), _ctx(),
58
+ _core.planUpdate(schemaName, condition ?? null, data ?? null, options ?? null, _nowFor(schemaName), _ctx(),
58
59
  found, doc, routeOverride)));
59
60
  const result = await _exec(out.command);
60
61
  return result ? _call(() => _core.applyWriteDefaults(schemaName, result)) : null;
@@ -63,28 +64,40 @@ async function update(schemaName, condition, data, options = null, routeOverride
63
64
  /** 批量更新(支持原生操作符) */
64
65
  async function updateMany(schemaName, condition, data, routeOverride = null) {
65
66
  const out = _call(() =>
66
- _core.planUpdateMany(schemaName, condition ?? null, data ?? null, _now(), _ctx(), routeOverride));
67
+ _core.planUpdateMany(schemaName, condition ?? null, data ?? null, _nowFor(schemaName), _ctx(), routeOverride));
67
68
  const result = await _exec(out.command);
68
69
  return { modifiedCount: result.modifiedCount };
69
70
  }
70
71
 
71
- /** 删除 —— 原表数据先归档到对应 `_deleted` 附表(附 deletedAt),再物理删除原表数据 */
72
+ /** 删除 —— 原表数据先归档到对应 `_deleted` 附表(附 deletedAt),再物理删除原表数据。
73
+ * 归档命令带 `upsertById`(幂等),重试不再因 _id 冲突整批失败;单一 SQL 源时
74
+ * 归档+删除整体事务化(Mongo / 跨源按顺序执行,非原子边界见 README「事务边界」) */
72
75
  async function remove(schemaName, condition, routeOverride = null) {
73
76
  const out = await _planWithProbe((found, doc) => _call(() =>
74
77
  _core.planRemove(schemaName, condition ?? null, _ctx(), found, doc, routeOverride)));
75
78
 
76
- let archivedCount = 0;
77
- if (out.findCommand) {
78
- const docs = await _exec(out.findCommand);
79
- if (docs.length) {
80
- const arch = _call(() => _core.planArchiveDocs(schemaName, docs, _now(), routeOverride));
81
- await _exec(arch.command);
82
- archivedCount = docs.length;
79
+ const doRemove = async () => {
80
+ let archivedCount = 0;
81
+ if (out.findCommand) {
82
+ const docs = await _exec(out.findCommand);
83
+ if (docs.length) {
84
+ const arch = _call(() => _core.planArchiveDocs(schemaName, docs, _nowFor(schemaName), routeOverride));
85
+ await _exec(arch.command);
86
+ archivedCount = docs.length;
87
+ }
83
88
  }
84
- }
85
89
 
86
- const result = await _exec(out.deleteCommand);
87
- return { deletedCount: result.deletedCount, archivedCount };
90
+ const result = await _exec(out.deleteCommand);
91
+ return { deletedCount: result.deletedCount, archivedCount };
92
+ };
93
+
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();
88
101
  }
89
102
 
90
103
  /** 判断是否存在 */
@@ -96,7 +109,7 @@ async function exists(schemaName, condition, routeOverride = null) {
96
109
 
97
110
  /** 统计符合条件的文档数量 */
98
111
  async function count(schemaName, filter = null, routeOverride = null) {
99
- const cmd = _call(() => _core.planCount(schemaName, filter ?? null, routeOverride));
112
+ const cmd = _call(() => _core.planCount(schemaName, filter ?? null, _ctx(), routeOverride));
100
113
  return _exec(cmd);
101
114
  }
102
115