nodejs-store 1.0.0 → 1.1.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
@@ -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
 
@@ -165,6 +169,22 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
165
169
  - No context set → permission checks disabled (backward compatible).
166
170
  - Denied access throws `store.PermissionError` (with `status = 403`).
167
171
 
172
+ ### Fail-secure mode (opt-in)
173
+
174
+ "No context" can mean both *system call* and *caller forgot the context* — by default the
175
+ latter silently passes every check (fail-open, kept for backward compatibility). For
176
+ security-sensitive hosts, enable the context requirement once at startup:
177
+
178
+ ```js
179
+ store.setRequireContext(true);
180
+ // now every query/write without a context throws `ERR_NO_CONTEXT:...`
181
+ // internal jobs must be explicit:
182
+ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
183
+ ```
184
+
185
+ `runAsInternal` marks the call as `{ internal: true }`, which is semantically distinct from
186
+ a missing context and always passes. `setRequireContext(false)` restores the default.
187
+
168
188
  ## Schema reference
169
189
 
170
190
  ```js
@@ -196,6 +216,102 @@ await store.runAsInternal(() => store.remove('Post', { _id: pid }));
196
216
 
197
217
  Types: `string | int | long | float | double | boolean | array | object | date | any`.
198
218
 
219
+ ## Advanced API
220
+
221
+ Everything below is reachable from the exported `store` singleton or the modules it
222
+ re-exports. Options prefixed with `?` are optional.
223
+
224
+ ### `store.buildPipeline(gql, params?)`
225
+
226
+ Low-level parse — compiles GQL to the command plan **without executing it**, returning
227
+ `{ tokens, ast, pipeline, projection }`. Useful for debugging query shape, asserting
228
+ pushdown behaviour, or building custom tooling. Permissions / computes are **not** applied here.
229
+
230
+ ```js
231
+ const plan = store.buildPipeline('Post($condition:@c0){ title }', { c0: { status: 'draft' } });
232
+ console.log(plan.pipeline);
233
+ ```
234
+
235
+ ### `store.syncSchema(opts)`
236
+
237
+ Pull a SQL backend's physical structure into the registry
238
+ (`introspect → schemaFromRows → mergeSchema(overlay) → register`). It only **reads** the
239
+ structure — it never writes DDL back to the database.
240
+
241
+ | Option | Type | Meaning |
242
+ | --- | --- | --- |
243
+ | `backend` | `'mysql' \| 'postgres' \| 'sqlite'` | required |
244
+ | `driver` | object | required; prefer a read-only account |
245
+ | `introspectOptions` | object | passed through to introspection (e.g. PG `schema`) |
246
+ | `overlay` | `Array` | local schemaJSON merged on top (permissions / computes / overrides) |
247
+ | `datasource` | string | bind every merged def to this source |
248
+ | `namespace` | string | bind every merged def to this namespace |
249
+ | `registerDefs` | boolean (default `true`) | `false` = return defs without registering |
250
+
251
+ Returns the merged `schemaJSON[]`.
252
+
253
+ ```js
254
+ const defs = await store.syncSchema({
255
+ backend: 'postgres', driver: pgPool, overlay: [Post], datasource: 'pg_a',
256
+ });
257
+ ```
258
+
259
+ ### `store.setAllowUserPipeline(allow = true)`
260
+
261
+ Registry-level guard for user-supplied `$pipeline` passthrough. Default is **allow**
262
+ (backward compatible); AI / 问数 hosts should call `store.setAllowUserPipeline(false)` as
263
+ defense in depth. `store.setRequireContext(...)` (fail-secure mode) is documented under
264
+ [Fail-secure mode](#fail-secure-mode-opt-in).
265
+
266
+ ### `store.setFeedbackSink(fn)`
267
+
268
+ Take over the unified feedback channel used for fallback / degradation / interception
269
+ events. The sink receives one event object; pass `null` (or a non-function) to fall back to
270
+ the default stderr printer.
271
+
272
+ ```js
273
+ store.setFeedbackSink((e) => logger.warn({ code: e.code }, e.hint));
274
+ // event shape: { type, code, layer, message, hint, ... }
275
+ // type federation_degraded | sql_pushdown_unsupported | ...
276
+ // code crossSourceSort | pushdownUnsupported | ...
277
+ // layer federation | dialect | ...
278
+ ```
279
+
280
+ ### Low-level modules
281
+
282
+ The package re-exports its building blocks for advanced hosts:
283
+
284
+ ```js
285
+ const {
286
+ init, store, Store,
287
+ aggregate, // standalone aggregate(schemaName, pipeline, routeOverride)
288
+ PermissionError, // thrown on denied access (status = 403)
289
+ PushdownUnsupportedError, // thrown when a command cannot be safely pushed down
290
+ datasource, schema, permission, crud, executors, feedback, introspect,
291
+ syncSchema, // same function as store.syncSchema
292
+ } = require('nodejs-store');
293
+
294
+ // introspect.run(backend, driver, options) → normalized structure rows
295
+ const rows = await introspect.run('mysql', pool, {});
296
+
297
+ // executors.createConnection(kind, driver, options) → SQL datasource descriptor { kind, exec }
298
+ await init({ default: db, pg_a: executors.createConnection('postgres', pgPool) });
299
+ ```
300
+
301
+ - `schema` / `permission` / `feedback` / `datasource` expose the same functions the `store`
302
+ singleton delegates to (e.g. `datasource.setConnections`, `datasource.hasConnection`,
303
+ `datasource.isSql`, `datasource.runInTransaction`).
304
+ - **Multi-tenant route override** — pass `{ source, namespace }` as the last argument of any
305
+ query/write, see [Multi-datasource connections](#multi-datasource-connections).
306
+
307
+ ## Transaction boundary
308
+
309
+ - **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.
310
+ - **Each SQL write command** is itself atomic: multi-statement plans (e.g. MySQL write + readback) are transaction-wrapped in the executor.
311
+ - **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.
312
+ - **Archive idempotency**: `remove` archives with upsert-by-`_id` semantics, so a retry after partial failure no longer fails on duplicate `_id`.
313
+ - **Cross-source steps** (parent and child bound to different datasources) cannot be atomic — they run sequentially by design.
314
+
199
315
  ## License
200
316
 
201
317
  [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-store",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -42,5 +44,11 @@
42
44
  "mysql2": "^3.24.4",
43
45
  "pg": "^8.23.0",
44
46
  "rust-store-node": "^1.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,31 +14,40 @@
14
14
 
15
15
  const { PermissionError, getContext } = require('../permission');
16
16
  const datasource = require('../datasource');
17
+ const { get: _getSchema } = require('../schema');
17
18
 
18
19
  const _PHASE1_IDS = /^\{\{phase1\.ids\}\}$/;
19
20
  const _STEP_PH = /^\{\{step\.(\d+)\._id\}\}$/;
20
21
 
21
- /** core 权限类错误消息 → PermissionError(消息与 core 常量保持一致) */
22
- const _PERMISSION_MSGS = new Set(['无访问权限', '无写入权限', '无删除权限', '无批量写入权限']);
22
+ /**
23
+ * 权限类错误识别:core 权限错误统一携带 `ERR_PERMISSION:` 稳定前缀(见 core
24
+ * `command/mod.rs::ERR_PERM_PREFIX`),按**前缀**映射而非具体文案 —— core 文案
25
+ * 可自由调整,映射不随文案漂移而静默失效。构造 PermissionError 时剥离前缀。
26
+ */
27
+ const _PERM_PREFIX = 'ERR_PERMISSION:';
23
28
 
24
29
  /** 设置数据源连接映射(对 `../datasource` 的路由入口做包内透出) */
25
30
  const setConnections = datasource.setConnections;
26
31
 
27
- /** 毫秒时间戳(Host 时钟源) */
28
- function _now() {
29
- return Date.now();
32
+ /** 按 schema 的 timestamps 单位产出当前时间戳('s' → 秒,其余/未启用 → 毫秒) */
33
+ function _nowFor(schemaName) {
34
+ const unit = _getSchema(schemaName).timestampUnit;
35
+ return unit === 's' ? Math.floor(Date.now() / 1000) : Date.now();
30
36
  }
31
37
 
32
38
  function _ctx() {
33
39
  return getContext() ?? null;
34
40
  }
35
41
 
36
- /** 绑定层调用包装:权限类错误映射为 PermissionError */
42
+ /** 绑定层调用包装:权限类错误(`ERR_PERMISSION:` 前缀)映射为 PermissionError */
37
43
  function _call(fn) {
38
44
  try {
39
45
  return fn();
40
46
  } catch (e) {
41
- if (_PERMISSION_MSGS.has(e && e.message)) throw new PermissionError(e.message);
47
+ const msg = e && e.message;
48
+ if (typeof msg === 'string' && msg.startsWith(_PERM_PREFIX)) {
49
+ throw new PermissionError(msg.slice(_PERM_PREFIX.length));
50
+ }
42
51
  throw e;
43
52
  }
44
53
  }
@@ -65,6 +74,14 @@ async function _execMongo(db, cmd) {
65
74
  await coll.insertOne(cmd.doc);
66
75
  return cmd.doc;
67
76
  case 'insertMany':
77
+ if (cmd.upsertById) {
78
+ // 归档幂等(core planArchiveDocs):按 _id 逐条覆盖 —— 「归档成功但删除失败」
79
+ // 的重试不再因 _id 冲突整批失败。SQL 侧由 dialect 的 ON CONFLICT/REPLACE 承接。
80
+ for (const doc of cmd.docs) {
81
+ await coll.replaceOne({ _id: doc._id }, doc, { upsert: true });
82
+ }
83
+ return { insertedCount: cmd.docs.length };
84
+ }
68
85
  await coll.insertMany(cmd.docs);
69
86
  return { insertedCount: cmd.docs.length };
70
87
  case 'findOneAndUpdate':
@@ -78,9 +95,10 @@ async function _execMongo(db, cmd) {
78
95
  }
79
96
  }
80
97
 
81
- /** 在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec) */
98
+ /** 在指定数据源上执行命令(Mongo 走原生驱动,SQL 走 translate → exec;
99
+ * 事务作用域内经 datasource.connectionFor 落到事务专用连接) */
82
100
  async function _execOn(source, cmd) {
83
- const connection = datasource.getConnection(source);
101
+ const connection = datasource.connectionFor(source);
84
102
  const db = datasource.mongoDb(connection, source, cmd.namespace ?? null);
85
103
  if (db) {
86
104
  return _execMongo(db, cmd);
@@ -129,7 +147,7 @@ function resolvePlaceholders(command, { ids = null, steps = [] } = {}) {
129
147
 
130
148
  module.exports = {
131
149
  setConnections,
132
- _now,
150
+ _nowFor,
133
151
  _ctx,
134
152
  _call,
135
153
  _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
@@ -19,7 +19,7 @@
19
19
  * - [`mutation`]:mutation / upsert / 原生聚合
20
20
  */
21
21
 
22
- const { setConnections, _now, _ctx, _call, _exec, _substitute, resolvePlaceholders } = require('./exec');
22
+ const { setConnections, _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');
@@ -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,36 @@
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 { _call, _ctx, _exec, _nowFor, resolvePlaceholders } = require('./exec');
9
10
  const { _generateId, _newIdPool } = require('./id');
10
11
 
11
12
  /** mutation 单条:规划步骤序列 → 依序执行 + 父子 _id 占位符回填 */
12
13
  async function _mutationOne(schemaName, data, routeOverride = null) {
13
14
  const plan = _call(() =>
14
- _core.planMutation(schemaName, data, _now(), _newIdPool(schemaName, data), _ctx(),
15
+ _core.planMutation(schemaName, data, _nowFor(schemaName), _newIdPool(schemaName, data), _ctx(),
15
16
  routeOverride));
16
17
 
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; // 首步即根写入
24
- }
18
+ const runSteps = async () => {
19
+ const resolved = [];
20
+ let rootResult = null;
21
+ for (const [i, step] of plan.steps.entries()) {
22
+ const cmd = resolvePlaceholders(step.command, { steps: resolved });
23
+ const result = await _exec(cmd);
24
+ resolved.push(result ? (result._id ?? null) : null);
25
+ if (i === 0) rootResult = result; // 首步即根写入
26
+ }
27
+
28
+ return rootResult ? _call(() => _core.applyWriteDefaults(schemaName, rootResult)) : null;
29
+ };
25
30
 
26
- return rootResult ? _call(() => _core.applyWriteDefaults(schemaName, rootResult)) : null;
31
+ // 单一 SQL 源 → 步骤序列整体事务化(同连接同事务,任一步失败整体回滚);
32
+ // Mongo 源 / 跨源步骤按原样顺序执行(非原子边界见 README「事务边界」)
33
+ const sources = [...new Set(plan.steps.map((s) => s.command.source || datasource.DEFAULT_SOURCE))];
34
+ if (sources.length === 1 && datasource.isSql(sources[0])) {
35
+ return datasource.runInTransaction(sources[0], runSteps);
36
+ }
37
+ return runSteps();
27
38
  }
28
39
 
29
40
  /**
@@ -54,7 +65,7 @@ async function mutation(schemaName, data, routeOverride = null) {
54
65
  async function upsert(schemaName, condition, data, options = null, routeOverride = null) {
55
66
  const s = _getSchema(schemaName);
56
67
  const plan = _call(() => _core.planUpsert(
57
- schemaName, condition ?? null, data ?? null, options ?? null, _now(),
68
+ schemaName, condition ?? null, data ?? null, options ?? null, _nowFor(schemaName),
58
69
  s.idPrefix ? _generateId(s) : '', _ctx(), routeOverride,
59
70
  ));
60
71
  const result = await _exec(plan.command);
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 → 关联 → 还原排序)/ 标准聚合 */
@@ -48,9 +49,10 @@ async function query(gql, params = null, routeOverride = null) {
48
49
  return _finalize(plan, await _runQueryPlan(plan));
49
50
  }
50
51
 
51
- /** GQL 查询(返回单条) */
52
+ /** GQL 查询(返回单条)—— 走 core `planQueryOne`:未显式 `$limit` 时下推 `$limit(1)` */
52
53
  async function queryOne(gql, params = null, routeOverride = null) {
53
- const items = await query(gql, params, routeOverride);
54
+ const plan = _call(() => _core.planQueryOne(gql, params ?? {}, _ctx(), routeOverride));
55
+ const items = await _finalize(plan, await _runQueryPlan(plan));
54
56
  return items.length ? items[0] : null;
55
57
  }
56
58
 
@@ -75,8 +77,8 @@ async function _runFederatedUnit(unit) {
75
77
  /**
76
78
  * 跨库联邦查询(返回嵌套文档数组)
77
79
  *
78
- * Host 四步:core `planFederated` 拆源 → 逐源执行 → core `mergeFederated`
79
- * 内存 hash join → 统一后处理(`_finalize`,与单库同一路径)。
80
+ * Host 四步:core `planFederated` 拆源 → 并行逐源执行(任一源失败整体失败)
81
+ * → core `mergeFederated` 内存 hash join → 统一后处理(`_finalize`,与单库同一路径)。
80
82
  *
81
83
  * `postprocess` 取自根单元快照(含全部关系),因此结果形状与单库 `query` 完全一致。
82
84
  * 每源取数上限 `MAX_FEDERATION_ROWS` 由 core 强制(超限即报错,拒绝静默全表拉取);
@@ -86,13 +88,11 @@ async function queryFederated(gql, params = null) {
86
88
  const plan = _call(() => _core.planFederated(gql, params ?? {}, _ctx()));
87
89
 
88
90
  for (const d of plan.degraded || []) {
89
- console.warn(`[federation] 降级 ${(d && d.code) || ''}: ${(d && d.message) || ''}`);
91
+ // 降级事件走统一反馈通道(无 sink 时打 stderr,允许拦截,禁止静默失守)
92
+ _emitFeedback({ ...(d || {}), type: 'federation_degraded' });
90
93
  }
91
94
 
92
- const results = [];
93
- for (const unit of plan.sources || []) {
94
- results.push(await _runFederatedUnit(unit));
95
- }
95
+ const results = await Promise.all((plan.sources || []).map(_runFederatedUnit));
96
96
 
97
97
  const merged = _call(() => _core.mergeFederated(plan, results));
98
98
  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
  /** 判断是否存在 */
package/src/datasource.js CHANGED
@@ -18,13 +18,48 @@
18
18
  * `{ default: db }`,保证既有单库调用零变更。
19
19
  */
20
20
 
21
+ const { AsyncLocalStorage } = require('node:async_hooks');
21
22
  const { core: _core, get: _getSchema } = require('./schema');
23
+ const { emit: _emitFeedback } = require('./feedback');
22
24
  const executors = require('./executors');
23
25
 
24
26
  const DEFAULT_SOURCE = 'default';
25
27
 
26
28
  let _connections = Object.create(null);
27
29
 
30
+ /** 事务作用域的连接覆盖:source → 事务描述符(见 runInTransaction) */
31
+ const _txStore = new AsyncLocalStorage();
32
+
33
+ /**
34
+ * SQL 下推遇到无法安全翻译的组合(core 标记 unsupported)
35
+ *
36
+ * 显式报错而非静默执行「缺少该段」的 SQL(会返回错误结果);
37
+ * 自动反馈:触发原因见 message,修复指引见 feedback()。
38
+ */
39
+ class PushdownUnsupportedError extends Error {
40
+ constructor(source, kind, codes, warnings) {
41
+ super(`SQL 下推不支持(${kind}): ${codes.join(', ')};${warnings.join(' / ')}`);
42
+ this.name = 'PushdownUnsupportedError';
43
+ this.source = source;
44
+ this.kind = kind;
45
+ this.codes = codes;
46
+ this.warnings = warnings;
47
+ }
48
+
49
+ /** 转统一反馈事件(与 feedback.emit 的事件形状一致) */
50
+ feedback() {
51
+ return {
52
+ type: 'sql_pushdown_unsupported',
53
+ code: 'pushdownUnsupported',
54
+ layer: 'dialect',
55
+ message: this.message,
56
+ hint: '改写查询避开该组合,或改用 Mongo 源执行该段取数',
57
+ source: this.source,
58
+ kind: this.kind,
59
+ };
60
+ }
61
+ }
62
+
28
63
  /** Mongo 形态判别:db 实例(collection 为函数)或 MongoClient(db 为函数且无 collection) */
29
64
  function _isMongoHandle(x) {
30
65
  return (
@@ -58,6 +93,64 @@ function getConnection(source) {
58
93
  return conn;
59
94
  }
60
95
 
96
+ /** 指定数据源是否已在当前连接映射中配置(辅助动作「软跳过」判定用,如 init 建索引) */
97
+ function hasConnection(source) {
98
+ return _connections[source] !== undefined;
99
+ }
100
+
101
+ /** 连接是否为 SQL 执行器描述符(`{ kind, exec }`;Mongo 为驱动实例) */
102
+ function isSqlConnection(connection) {
103
+ return (
104
+ !!connection &&
105
+ typeof connection.kind === 'string' &&
106
+ typeof connection.exec === 'function'
107
+ );
108
+ }
109
+
110
+ /** 数据源名是否绑定 SQL 源 */
111
+ function isSql(source) {
112
+ return isSqlConnection(getConnection(source));
113
+ }
114
+
115
+ /**
116
+ * 当前生效连接:事务作用域内返回覆盖描述符,否则返回全局映射的连接
117
+ * (`exec.js#_execOn` 经此取连接,使事务内所有命令落到专用连接)
118
+ */
119
+ function connectionFor(source) {
120
+ const store = _txStore.getStore();
121
+ if (store && store.has(source)) return store.get(source);
122
+ return getConnection(source);
123
+ }
124
+
125
+ /**
126
+ * 事务作用域:在单个 SQL 源上以「同连接 + 同事务」执行 fn 内的全部命令
127
+ *
128
+ * - fn 内经 `_exec` 路由到该 source 的命令全部落到事务连接(commit/rollback 一体);
129
+ * - Mongo 源 / 执行器未实现 withTransaction / 多源混合时按原样执行
130
+ * (跨源无法原子 —— 信任边界见 README「事务边界」),绝不静默假装已事务化;
131
+ * - 事务体抛错统一 rollback 后原样上抛。
132
+ */
133
+ async function runInTransaction(source, fn) {
134
+ const conn = getConnection(source);
135
+ if (!isSqlConnection(conn) || typeof conn.withTransaction !== 'function') {
136
+ return fn();
137
+ }
138
+ const parent = _txStore.getStore();
139
+ const store = new Map(parent || []);
140
+ if (store.has(source)) {
141
+ // 同源嵌套事务:外层已持有该源的事务连接,内层并入外层(不做保存点)
142
+ return fn();
143
+ }
144
+ const txDescriptor = { kind: conn.kind, exec: null };
145
+ store.set(source, txDescriptor);
146
+ return _txStore.run(store, () =>
147
+ conn.withTransaction(async (execOnTx) => {
148
+ txDescriptor.exec = execOnTx;
149
+ return fn();
150
+ }),
151
+ );
152
+ }
153
+
61
154
  /**
62
155
  * Mongo 源:按命令的 `namespace` 解析目标 db(两种形态,绝不猜)
63
156
  *
@@ -126,10 +219,15 @@ async function execSql(source, connection, cmd) {
126
219
  // Host 兜底:core 标记了无法安全下推的组合(如 $lookup 子 $limit 每父 top-N)时,
127
220
  // 绝不执行「缺少该段」的 SQL(会静默返回错误结果),改为显式报错,由调用方降级重查。
128
221
  if (Array.isArray(plan.unsupported) && plan.unsupported.length > 0) {
129
- const codes = plan.unsupported.map((u) => (u && u.code) || String(u)).join(', ');
130
- throw new Error(
131
- `SQL 下推不支持(${connection.kind}): ${codes};${(plan.warnings || []).join(' / ')}`,
222
+ const err = new PushdownUnsupportedError(
223
+ source,
224
+ connection.kind,
225
+ plan.unsupported.map((u) => (u && u.code) || String(u)),
226
+ (plan.warnings || []).map((w) => String(w)),
132
227
  );
228
+ // 自动反馈:拦截即告警(无 sink 时打 stderr),禁止静默失守
229
+ _emitFeedback(err.feedback());
230
+ throw err;
133
231
  }
134
232
  const out = await connection.exec(plan);
135
233
  return executors.shapeResult(cmd, out);
@@ -139,10 +237,16 @@ module.exports = {
139
237
  DEFAULT_SOURCE,
140
238
  setConnections,
141
239
  getConnection,
240
+ hasConnection,
241
+ isSqlConnection,
242
+ isSql,
243
+ connectionFor,
244
+ runInTransaction,
142
245
  mongoDb,
143
246
  sourceOfSchema,
144
247
  connectionOfSchema,
145
248
  dbOfSchema,
146
249
  route,
147
250
  execSql,
251
+ PushdownUnsupportedError,
148
252
  };
@@ -23,22 +23,49 @@ function create(driver, _options = {}) {
23
23
  if (!driver || typeof driver.execute !== 'function') {
24
24
  throw new TypeError('mysql 执行器需要 mysql2/promise 的连接或连接池');
25
25
  }
26
+
27
+ /** 在指定连接上依序执行 plan.stmts(多语句 plan 由 withTransaction 包事务) */
28
+ async function runStmts(conn, plan) {
29
+ let docs = null;
30
+ let rows = null;
31
+ let affectedRows = 0;
32
+ for (const stmt of plan.stmts) {
33
+ const [raw] = await conn.execute(stmt.text, stmt.params || []);
34
+ if (Array.isArray(raw)) {
35
+ rows = raw.map(_plain);
36
+ if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
37
+ } else {
38
+ affectedRows = Number(raw.affectedRows || 0);
39
+ }
40
+ }
41
+ return { docs, rows, affectedRows };
42
+ }
43
+
26
44
  return {
27
45
  kind: 'mysql',
28
- async exec(plan) {
29
- let docs = null;
30
- let rows = null;
31
- let affectedRows = 0;
32
- for (const stmt of plan.stmts) {
33
- const [raw] = await driver.execute(stmt.text, stmt.params || []);
34
- if (Array.isArray(raw)) {
35
- rows = raw.map(_plain);
36
- if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
37
- } else {
38
- affectedRows = Number(raw.affectedRows || 0);
46
+ exec: (plan) => runStmts(driver, plan),
47
+ /**
48
+ * 事务执行:body(executeOnTx) 的所有 plan 落在同一连接同一事务内,
49
+ * 成功 commit / 失败 rollback。池自动取专用连接(结束归还)。
50
+ */
51
+ async withTransaction(body) {
52
+ const conn =
53
+ typeof driver.getConnection === 'function' ? await driver.getConnection() : driver;
54
+ try {
55
+ await conn.beginTransaction();
56
+ const out = await body((plan) => runStmts(conn, plan));
57
+ await conn.commit();
58
+ return out;
59
+ } catch (e) {
60
+ try {
61
+ await conn.rollback();
62
+ } catch (_) {
63
+ /* rollback 失败不掩盖原始错误 */
39
64
  }
65
+ throw e;
66
+ } finally {
67
+ if (conn !== driver && typeof conn.release === 'function') conn.release();
40
68
  }
41
- return { docs, rows, affectedRows };
42
69
  },
43
70
  };
44
71
  }
@@ -16,19 +16,58 @@ function create(driver, _options = {}) {
16
16
  if (!driver || typeof driver.query !== 'function') {
17
17
  throw new TypeError('postgres 执行器需要 pg 的 Pool/Client 实例');
18
18
  }
19
+
20
+ /** 在指定连接上依序执行 plan.stmts */
21
+ async function runStmts(conn, plan) {
22
+ let docs = null;
23
+ let rows = null;
24
+ let affectedRows = 0;
25
+ for (const stmt of plan.stmts) {
26
+ const res = await conn.query(stmt.text, stmt.params || []);
27
+ rows = res.rows || [];
28
+ affectedRows = Number(res.rowCount || 0);
29
+ if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
30
+ }
31
+ return { docs, rows, affectedRows };
32
+ }
33
+
19
34
  return {
20
35
  kind: 'postgres',
21
- async exec(plan) {
22
- let docs = null;
23
- let rows = null;
24
- let affectedRows = 0;
25
- for (const stmt of plan.stmts) {
26
- const res = await driver.query(stmt.text, stmt.params || []);
27
- rows = res.rows || [];
28
- affectedRows = Number(res.rowCount || 0);
29
- if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
36
+ exec: (plan) => runStmts(driver, plan),
37
+ /**
38
+ * 事务执行:显式 BEGIN/COMMIT/ROLLBACK 包住 body 的全部 plan。
39
+ * Pool 自动 checkout 专用 client(`release()` 归还);Client 直连直接用。
40
+ */
41
+ async withTransaction(body) {
42
+ let conn = driver;
43
+ let release = null;
44
+ if (typeof driver.connect === 'function') {
45
+ try {
46
+ const c = await driver.connect();
47
+ // Pool.connect() → 专用 Client(带 release);Client.connect() → 自身
48
+ if (c && typeof c.query === 'function') {
49
+ conn = c;
50
+ if (c !== driver && typeof c.release === 'function') release = () => c.release();
51
+ }
52
+ } catch (_) {
53
+ /* checkout 失败退回 driver 本体,事务语义由 BEGIN/COMMIT 保证 */
54
+ }
55
+ }
56
+ try {
57
+ await conn.query('BEGIN');
58
+ const out = await body((plan) => runStmts(conn, plan));
59
+ await conn.query('COMMIT');
60
+ return out;
61
+ } catch (e) {
62
+ try {
63
+ await conn.query('ROLLBACK');
64
+ } catch (_) {
65
+ /* rollback 失败不掩盖原始错误 */
66
+ }
67
+ throw e;
68
+ } finally {
69
+ if (release) release();
30
70
  }
31
- return { docs, rows, affectedRows };
32
71
  },
33
72
  };
34
73
  }
@@ -25,23 +25,43 @@ function create(db, _options = {}) {
25
25
  if (!db || typeof db.prepare !== 'function') {
26
26
  throw new TypeError('sqlite 执行器需要 better-sqlite3 Database 实例');
27
27
  }
28
+
29
+ /** 依序执行 plan.stmts(better-sqlite3 同步单连接) */
30
+ function runStmts(plan) {
31
+ let docs = null;
32
+ let rows = null;
33
+ let affectedRows = 0;
34
+ for (const stmt of plan.stmts) {
35
+ const params = _bind(stmt.params);
36
+ // 带 RETURNING 的写语句同样返回行 → 必须用 all() 取回;其余写语句用 run() 取影响行数
37
+ if (!stmt.isWrite || stmt.rowShape) {
38
+ rows = db.prepare(stmt.text).all(...params);
39
+ if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
40
+ } else {
41
+ affectedRows = Number(db.prepare(stmt.text).run(...params).changes || 0);
42
+ }
43
+ }
44
+ return { docs, rows, affectedRows };
45
+ }
46
+
28
47
  return {
29
48
  kind: 'sqlite',
30
- exec(plan) {
31
- let docs = null;
32
- let rows = null;
33
- let affectedRows = 0;
34
- for (const stmt of plan.stmts) {
35
- const params = _bind(stmt.params);
36
- // 带 RETURNING 的写语句同样返回行 → 必须用 all() 取回;其余写语句用 run() 取影响行数
37
- if (!stmt.isWrite || stmt.rowShape) {
38
- rows = db.prepare(stmt.text).all(...params);
39
- if (stmt.rowShape) docs = _core.restoreRows(stmt.rowShape, rows);
40
- } else {
41
- affectedRows = Number(db.prepare(stmt.text).run(...params).changes || 0);
49
+ exec: runStmts,
50
+ /** 事务执行:显式 BEGIN/COMMIT/ROLLBACK(better-sqlite3 默认 autocommit,显式开事务安全) */
51
+ async withTransaction(body) {
52
+ db.exec('BEGIN');
53
+ try {
54
+ const out = await body(runStmts);
55
+ db.exec('COMMIT');
56
+ return out;
57
+ } catch (e) {
58
+ try {
59
+ db.exec('ROLLBACK');
60
+ } catch (_) {
61
+ /* rollback 失败不掩盖原始错误 */
42
62
  }
63
+ throw e;
43
64
  }
44
- return { docs, rows, affectedRows };
45
65
  },
46
66
  };
47
67
  }
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 反馈事件通道 —— 兜底/降级/拦截触发的统一出口
5
+ *
6
+ * 事件形状(对齐 rust-store 联邦 degraded 契约)::
7
+ *
8
+ * {type, code, layer, message, hint, ...}
9
+ * - type : 事件类别(federation_degraded / sql_pushdown_unsupported ...)
10
+ * - code : 机器可读代码(crossSourceSort / pushdownUnsupported ...)
11
+ * - layer : 命中的防护层(federation / dialect ...)
12
+ * - message: 人可读描述
13
+ * - hint : 修复指引(供上游排查/加固)
14
+ *
15
+ * 默认无 sink 时打 stderr(向后兼容);宿主可 `setSink(fn)` 接管,
16
+ * 接入自动反馈闭环(允许被拦截,禁止静默失守)。
17
+ */
18
+
19
+ let _sink = null;
20
+
21
+ /** 注册反馈事件回调 `fn(event)`;传 null/非函数恢复默认 stderr 行为 */
22
+ function setSink(fn) {
23
+ _sink = typeof fn === 'function' ? fn : null;
24
+ }
25
+
26
+ /** 产出一条反馈事件:有 sink 回调之;否则打印 stderr(允许拦截,禁止静默) */
27
+ function emit(event) {
28
+ const e = event || {};
29
+ if (_sink) {
30
+ _sink(e);
31
+ return;
32
+ }
33
+ console.error(
34
+ `[nodejs-store][${e.layer || '?'}/${e.code || '?'}] `
35
+ + `${e.message || ''}(hint: ${e.hint || '-'})`,
36
+ );
37
+ }
38
+
39
+ module.exports = { setSink, emit };
package/src/index.js CHANGED
@@ -24,6 +24,7 @@
24
24
  const crud = require('./crud');
25
25
  const datasource = require('./datasource');
26
26
  const executors = require('./executors');
27
+ const feedback = require('./feedback');
27
28
  const introspect = require('./introspect');
28
29
  const permission = require('./permission');
29
30
  const schema = require('./schema');
@@ -51,6 +52,7 @@ class Store {
51
52
  /**
52
53
  * GQL 查询。`routeOverride`(可选):`{ source?, namespace? }` 多租户路由,
53
54
  * 覆盖命令定位(权限/计算列仍按结构 schema 判定)。下同。
55
+ * 注意:`routeOverride` 为**受信服务端参数**,禁止透传用户输入(否则可被用于跨源路由,CWE-639)。
54
56
  */
55
57
  async query(gql, params, routeOverride) {
56
58
  return crud.query(gql, params, routeOverride);
@@ -122,6 +124,25 @@ class Store {
122
124
  return schema.core.buildPipeline(gql, params ?? {}, permission.getContext() ?? null);
123
125
  }
124
126
 
127
+ // ── 宿主接入守卫(Registry 级,对齐 py-store c44001e) ──
128
+ /** 开关用户 $pipeline 直通(默认允许;AI 问数宿主建议关闭作纵深防御) */
129
+ setAllowUserPipeline(allow = true) {
130
+ return schema.setAllowUserPipeline(allow);
131
+ }
132
+
133
+ /**
134
+ * 开关「上下文强制」(默认关闭 = fail-open)。开启后:所有查询/写入在 ctx 缺失时
135
+ * 抛 `ERR_NO_CONTEXT`(fail-secure);内部调用须显式传 `{ internal: true }` 上下文。
136
+ */
137
+ setRequireContext(require = true) {
138
+ return schema.setRequireContext(require);
139
+ }
140
+
141
+ /** 注册反馈事件回调(兜底/降级/拦截的统一出口);传 null 恢复默认 stderr */
142
+ setFeedbackSink(fn) {
143
+ return feedback.setSink(fn);
144
+ }
145
+
125
146
  // ── 权限控制(AsyncLocalStorage 上下文) ──
126
147
  setContext(ctx) {
127
148
  return permission.setContext(ctx);
@@ -156,13 +177,10 @@ async function _createIndexesIfNeeded() {
156
177
  for (const name of names) {
157
178
  const s = schema.get(name);
158
179
  // 索引创建是初始化的辅助动作(非命令路由):schema 绑定的 source 暂未在
159
- // 当前连接映射中时跳过,不阻塞 init(命令路由的 fail fast 不在此处)
160
- let db;
161
- try {
162
- db = datasource.dbOfSchema(name); // Mongo 按 (datasource, namespace) 解析;SQL 源返回 null
163
- } catch (e) {
164
- continue;
165
- }
180
+ // 当前连接映射中时软跳过,不阻塞 init;其余配置错误(namespace 形态不匹配等)
181
+ // 按 fail-fast 由 dbOfSchema 上抛,不静默吞掉
182
+ if (!datasource.hasConnection(datasource.sourceOfSchema(name))) continue;
183
+ const db = datasource.dbOfSchema(name); // Mongo 按 (datasource, namespace) 解析;SQL 源返回 null
166
184
  if (!db) continue; // SQL 后端不建索引
167
185
 
168
186
  const coll = db.collection(s.collection);
@@ -171,7 +189,10 @@ async function _createIndexesIfNeeded() {
171
189
  try {
172
190
  existingIndexes = await coll.listIndexes().toArray();
173
191
  } catch (e) {
174
- existingIndexes = [];
192
+ // 只吞服务器错误(集合尚未存在 → NamespaceNotFound 属 MongoServerError);
193
+ // 连接/程序错误按 fail-fast 上抛,不再静默吞掉(对齐 py 侧 PyMongoError 收窄)
194
+ if (e && e.name === 'MongoServerError') existingIndexes = [];
195
+ else throw e;
175
196
  }
176
197
 
177
198
  for (const idx of s.indexes || []) {
@@ -226,11 +247,13 @@ module.exports = {
226
247
  Store,
227
248
  aggregate: crud.aggregate,
228
249
  PermissionError: permission.PermissionError,
250
+ PushdownUnsupportedError: datasource.PushdownUnsupportedError,
229
251
  datasource,
230
252
  schema,
231
253
  permission,
232
254
  crud,
233
255
  executors,
256
+ feedback,
234
257
  introspect,
235
258
  syncSchema,
236
259
  };
package/src/schema.js CHANGED
@@ -52,6 +52,8 @@ function register(defn) {
52
52
  namespace: defn.namespace || null,
53
53
  idPrefix: defn.idPrefix || '',
54
54
  timestamps: defn.timestamps !== false,
55
+ // 时间戳单位('ms'/'s'/null=不维护);值合法性由 core.register 校验
56
+ timestampUnit: defn.timestamps === 's' ? 's' : (defn.timestamps === false ? null : 'ms'),
55
57
  fields: defn.fields || {},
56
58
  relations: defn.relations || {},
57
59
  computes,
@@ -98,9 +100,24 @@ function list() {
98
100
  return core.list();
99
101
  }
100
102
 
103
+ /** 开关用户 $pipeline 直通(默认允许;AI 问数宿主建议关闭作纵深防御) */
104
+ function setAllowUserPipeline(allow = true) {
105
+ core.setAllowUserPipeline(Boolean(allow));
106
+ }
107
+
108
+ /**
109
+ * 开关「上下文强制」(默认关闭 = fail-open,与 JS 原版语义一致)。
110
+ *
111
+ * 开启后:所有 plan 入口遇 ctx 缺失抛 `ERR_NO_CONTEXT` 错误(fail-secure);
112
+ * 内部调用(索引创建、归档回填、后台任务等)须显式传 `{ internal: true }` 上下文。
113
+ */
114
+ function setRequireContext(require = true) {
115
+ core.setRequireContext(Boolean(require));
116
+ }
117
+
101
118
  /** 取 asyncFn 计算列实现(fnRef 缺省 = 计算列 key 名) */
102
119
  function getAsyncFn(fnRef) {
103
120
  return _asyncFns[fnRef];
104
121
  }
105
122
 
106
- module.exports = { core, register, get, has, list, getAsyncFn };
123
+ module.exports = { core, register, get, has, list, setAllowUserPipeline, setRequireContext, getAsyncFn };