nodejs-store 2.1.0 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-store",
3
- "version": "2.1.0",
3
+ "version": "2.2.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
@@ -15,6 +15,7 @@
15
15
  const { PermissionError, getContext } = require('../permission');
16
16
  const datasource = require('../datasource');
17
17
  const { execMongo } = require('../executors/mongo');
18
+ const { emit: _emitFeedback } = require('../feedback');
18
19
  const { get: _getSchema } = require('../schema');
19
20
 
20
21
  const _PHASE1_IDS = /^\{\{phase1\.ids\}\}$/;
@@ -27,6 +28,36 @@ const _STEP_PH = /^\{\{step\.(\d+)\._id\}\}$/;
27
28
  */
28
29
  const _PERM_PREFIX = 'ERR_PERMISSION:';
29
30
 
31
+ /**
32
+ * 档位类错误识别:core text2query 档门禁统一携带 `ERR_TEXT2QUERY:` 稳定前缀
33
+ * (见 core `command/mod.rs::ERR_TEXT2QUERY`),同上按前缀映射。命中即 emit
34
+ * 反馈事件 `profile_blocked`(自动反馈原则:允许拦截,禁止静默)。
35
+ */
36
+ const _PROFILE_PREFIX = 'ERR_TEXT2QUERY:';
37
+
38
+ /**
39
+ * 从 core 文案 `... [$feature](功能收缩)` 中提取门禁项名;无 `[..]` 时留白
40
+ * (null),不伪造 feature —— 缺值必须显式暴露(禁静默兜底)。
41
+ */
42
+ const _FEATURE_RE = /\[(.+?)\]/;
43
+
44
+ /** 档位拦截反馈的统一提示(反馈事件契约 §4.6 的一部分;单点定义防文案漂移) */
45
+ const _PROFILE_HINT = '上游(LLM 产出的 GQL / 调用方入参)越界;text2query 档白名单见 SKILL.md §后端无关性与边界';
46
+
47
+ /**
48
+ * 档位(profile)拒绝:text2query 档违反功能收缩 / 硬限制
49
+ *
50
+ * 与权限错误(`PermissionError`,403)区分:档位拒绝是**调用方合约违反**(400),
51
+ * 非授权问题(见执行文档 §4.4)。`status` 供上层(HTTP 网关等)映射响应码。
52
+ */
53
+ class ProfileViolation extends Error {
54
+ constructor(message, status = 400) {
55
+ super(message);
56
+ this.name = 'ProfileViolation';
57
+ this.status = status;
58
+ }
59
+ }
60
+
30
61
  /** 设置数据源连接映射(对 `../datasource` 的路由入口做包内透出) */
31
62
  const setConnections = datasource.setConnections;
32
63
 
@@ -45,7 +76,14 @@ function _ctx() {
45
76
  return getContext() ?? null;
46
77
  }
47
78
 
48
- /** 绑定层调用包装:权限类错误(`ERR_PERMISSION:` 前缀)映射为 PermissionError */
79
+ /**
80
+ * 绑定层调用包装:
81
+ * - 权限类错误(`ERR_PERMISSION:` 前缀)→ PermissionError
82
+ * - 档位类错误(`ERR_TEXT2QUERY:` 前缀)→ emit `profile_blocked` 反馈 + ProfileViolation
83
+ *
84
+ * 按前缀映射而非具体文案(core 文案可自由调整,映射不随文案漂移而静默失效)。
85
+ * 其余异常原样上抛(不吞错)。
86
+ */
49
87
  function _call(fn) {
50
88
  try {
51
89
  return fn();
@@ -54,6 +92,20 @@ function _call(fn) {
54
92
  if (typeof msg === 'string' && msg.startsWith(_PERM_PREFIX)) {
55
93
  throw new PermissionError(msg.slice(_PERM_PREFIX.length));
56
94
  }
95
+ if (typeof msg === 'string' && msg.startsWith(_PROFILE_PREFIX)) {
96
+ const detail = msg.slice(_PROFILE_PREFIX.length);
97
+ const m = _FEATURE_RE.exec(detail);
98
+ _emitFeedback({
99
+ type: 'profile_blocked',
100
+ code: 'profileBlocked',
101
+ layer: 'core',
102
+ profile: 'text2query',
103
+ feature: m ? m[1] : null,
104
+ message: detail,
105
+ hint: _PROFILE_HINT,
106
+ });
107
+ throw new ProfileViolation(detail);
108
+ }
57
109
  throw e;
58
110
  }
59
111
  }
@@ -116,6 +168,8 @@ module.exports = {
116
168
  _nowFor,
117
169
  _ctx,
118
170
  _call,
171
+ ProfileViolation,
172
+ _PROFILE_HINT,
119
173
  _exec,
120
174
  _execOn,
121
175
  _substitute,
package/src/crud/index.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * - [`mutation`]:mutation / upsert
20
20
  */
21
21
 
22
- const { setConnections, setDb, _nowFor, _ctx, _call, _exec, _substitute, resolvePlaceholders } = require('./exec');
22
+ const { setConnections, setDb, _nowFor, _ctx, _call, ProfileViolation, _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');
@@ -41,6 +41,7 @@ module.exports = {
41
41
  count,
42
42
  mutation,
43
43
  upsert,
44
+ ProfileViolation,
44
45
  // ── Host 契约件(供跨语言同构契约测试与高级用法;下划线表示内部语义) ──
45
46
  _substitute,
46
47
  resolvePlaceholders,
package/src/crud/query.js CHANGED
@@ -5,9 +5,34 @@
5
5
  * / 跨库联邦(逐源执行 → 内存 hash join)
6
6
  */
7
7
 
8
- const { core: _core, getAsyncFn } = require('../schema');
8
+ const { core: _core, getAsyncFn, getProfile } = require('../schema');
9
9
  const { emit: _emitFeedback } = require('../feedback');
10
- const { _call, _ctx, _exec, _execOn, resolvePlaceholders } = require('./exec');
10
+ const {
11
+ _call, _ctx, _exec, _execOn, resolvePlaceholders, ProfileViolation, _PROFILE_HINT,
12
+ } = require('./exec');
13
+
14
+ /**
15
+ * Host 兜底:text2query 档禁用 `routeOverride`(受信参数,禁 AI 侧指定)
16
+ *
17
+ * 判决唯一在 core(执行文档 §4.2 ⑤:各 `planQuery*` 入口已判并 Err);此为第二层
18
+ * 防护——即使 core 判决被绕过,Host 也不放行受信参数(CWE-639)。命中即 emit
19
+ * `profile_blocked`,`layer: 'host'` 本身即反馈:core 层未拦住,须回溯加固
20
+ * (自动反馈原则:允许拦截,禁止静默)。
21
+ */
22
+ function _guardRouteOverride(routeOverride) {
23
+ if (routeOverride == null || getProfile() !== 'text2query') return;
24
+ const detail = 'text2query 档禁用 route_override(受信参数,禁 AI 侧指定)';
25
+ _emitFeedback({
26
+ type: 'profile_blocked',
27
+ code: 'profileBlocked',
28
+ layer: 'host',
29
+ profile: 'text2query',
30
+ feature: 'route_override',
31
+ message: detail,
32
+ hint: _PROFILE_HINT,
33
+ });
34
+ throw new ProfileViolation(detail);
35
+ }
11
36
 
12
37
  /** 执行读命令序列:find 快路径 / 两阶段(取 ID → 关联 → 还原排序)/ 标准聚合 */
13
38
  async function _runQueryPlan(plan) {
@@ -44,12 +69,14 @@ async function _finalize(plan, items) {
44
69
  * 权限/计算列仍按结构 schema 判定(见 multi-datasource-routing-plan.md §6)。
45
70
  */
46
71
  async function query(gql, params = null, routeOverride = null) {
72
+ _guardRouteOverride(routeOverride);
47
73
  const plan = _call(() => _core.planQuery(gql, params ?? {}, _ctx(), routeOverride));
48
74
  return _finalize(plan, await _runQueryPlan(plan));
49
75
  }
50
76
 
51
77
  /** GQL 查询(返回单条)—— 走 core `planQueryOne`:未显式 `$limit` 时下推 `$limit(1)` */
52
78
  async function queryOne(gql, params = null, routeOverride = null) {
79
+ _guardRouteOverride(routeOverride);
53
80
  const plan = _call(() => _core.planQueryOne(gql, params ?? {}, _ctx(), routeOverride));
54
81
  const items = await _finalize(plan, await _runQueryPlan(plan));
55
82
  return items.length ? items[0] : null;
@@ -106,6 +133,7 @@ async function queryFederated(gql, params = null) {
106
133
  * pageSize 上限 5000,防止拖库。
107
134
  */
108
135
  async function queryWithCount(gql, params = null, routeOverride = null) {
136
+ _guardRouteOverride(routeOverride);
109
137
  const plan = _call(() =>
110
138
  _core.planQueryWithCount(gql, params ?? {}, _ctx(), null, routeOverride));
111
139
  const items = await _finalize(plan, await _runQueryPlan(plan));
@@ -119,4 +147,6 @@ async function queryWithCount(gql, params = null, routeOverride = null) {
119
147
  };
120
148
  }
121
149
 
122
- module.exports = { query, queryOne, queryWithCount, queryFederated };
150
+ module.exports = {
151
+ query, queryOne, queryWithCount, queryFederated, _guardRouteOverride,
152
+ };
package/src/ddl.js CHANGED
@@ -4,7 +4,9 @@
4
4
  * DDL 生成(schema def → CREATE TABLE 文本;纯函数,不连库、不回写)
5
5
  *
6
6
  * 与 core 契约严格对齐(schema→DDL 单向映射):
7
- * - 只对 scalar 字段建列(object/array 不建列;同 core dialect::scalar_column);
7
+ * - 标量字段按声明类型建列;object/array 字段建 **JSON 列**(MySQL `JSON` / PG `jsonb` /
8
+ * SQLite `TEXT`,同 core dialect::Backend::json_type_name)——落单列存 JSON 文本,
9
+ * 读侧由 core row::parse_json_col 还原为嵌套对象,跨后端对齐 Mongo 嵌套文档;
8
10
  * - 每表必建 __present 哨兵列(形态 ,f1,f2,;同 core write/insert.rs::present_value);
9
11
  * - timestamps !== false → 追加 createdAt / updatedAt(同 core schema/registry.rs::add_timestamp_fields);
10
12
  * - 归档表 <collection>_deleted 由 registry 自动派生,本模块按已注册 def 逐表生成(不特判);
@@ -33,6 +35,8 @@ const TYPES = {
33
35
  date: ['BIGINT', 'BIGINT', 'INTEGER'],
34
36
  };
35
37
  const NON_COLUMN = ['object', 'array'];
38
+ // object/array 字段的列类型(JSON 文本列;同 core Backend::json_type_name)
39
+ const JSON_TYPE = ['JSON', 'jsonb', 'TEXT'];
36
40
  const ID_TYPE = ['VARCHAR(64)', 'TEXT', 'TEXT'];
37
41
  const PRESENT_TYPE = ['VARCHAR(255)', 'TEXT', 'TEXT'];
38
42
  const TIMESTAMP_FIELDS = ['createdAt', 'updatedAt'];
@@ -52,18 +56,22 @@ function declaredType(fieldDef) {
52
56
  return fieldDef && typeof fieldDef === 'object' ? fieldDef.type : fieldDef;
53
57
  }
54
58
 
55
- /** 返回 [[name, sqlType, pk]],顺序:声明的标量字段 → timestamps → __present */
59
+ /** 返回 [[name, sqlType, pk]],顺序:声明的字段(标量 / object·array JSON 列)→ timestamps → __present */
56
60
  function columns(defn, backend) {
57
61
  const i = idx(backend);
58
62
  const cols = [];
59
63
  const fields = defn.fields || {};
60
64
  for (const [name, fdef] of Object.entries(fields)) {
61
65
  const ftype = declaredType(fdef);
62
- if (NON_COLUMN.includes(ftype)) continue;
63
66
  if (name === '_id') {
64
67
  cols.push([name, ID_TYPE[i], true]);
65
68
  continue;
66
69
  }
70
+ if (NON_COLUMN.includes(ftype)) {
71
+ // object/array → 单列 JSON 文本(同 core field_column_ref::Json)
72
+ cols.push([name, JSON_TYPE[i], false]);
73
+ continue;
74
+ }
67
75
  if (!Object.prototype.hasOwnProperty.call(TYPES, ftype)) {
68
76
  throw new Error(
69
77
  `DDL 生成:字段 "${defn.name}.${name}" 类型 ${JSON.stringify(ftype)} 未知,支持 ${Object.keys(TYPES).sort()}`,
package/src/index.js CHANGED
@@ -21,6 +21,8 @@
21
21
  * const items = await store.query('Model($condition:@c0) { field1, field2 }', { c0: {} });
22
22
  */
23
23
 
24
+ const { AsyncLocalStorage } = require('node:async_hooks');
25
+
24
26
  const crud = require('./crud');
25
27
  const datasource = require('./datasource');
26
28
  const ddl = require('./ddl');
@@ -31,6 +33,29 @@ const permission = require('./permission');
31
33
  const schema = require('./schema');
32
34
  const { syncSchema } = require('./sync');
33
35
 
36
+ /** 档位 AsyncLocalStorage:记录「进入 text2query 前的原档」,供退出恢复(嵌套安全) */
37
+ const _profileAls = new AsyncLocalStorage();
38
+
39
+ /**
40
+ * 以 text2query 档执行(功能收缩 + 硬限制),退出恢复原档位。
41
+ *
42
+ * AI 问数链路入口;与 permission.scopedRoles 同构(token-set/reset,嵌套安全)。
43
+ * 档位是 core 进程级状态(非本 ALS 隔离),ALS 仅记录「进入时的原档」以便正确恢复,
44
+ * 使异步 / 嵌套调用各自回到自己进入前的档位。进入档位即等效强制携带用户上下文
45
+ * (core `ensureProfileCtx`,见执行文档 §4.2)。
46
+ */
47
+ async function text2query(fn) {
48
+ const prev = schema.getProfile();
49
+ schema.setProfile('text2query');
50
+ return _profileAls.run(prev, async () => {
51
+ try {
52
+ return await fn();
53
+ } finally {
54
+ schema.setProfile(prev);
55
+ }
56
+ });
57
+ }
58
+
34
59
  class Store {
35
60
  // ── Schema 管理 ──
36
61
  register(defn) {
@@ -161,6 +186,26 @@ class Store {
161
186
  return schema.requireContext();
162
187
  }
163
188
 
189
+ // ── 查询档位(判决唯一在 core):standard 默认放开 / text2query 功能收缩 ──
190
+ /**
191
+ * 设置查询档位:`'standard'`(默认,功能最大化 + 跨 DB 对齐)/
192
+ * `'text2query'`(功能收缩 + 硬限制)。进入档即等效强制 ctx;
193
+ * 未知档由 core 抛错(禁静默回落默认档)。
194
+ */
195
+ setProfile(profile) {
196
+ return schema.setProfile(profile);
197
+ }
198
+
199
+ /** 当前查询档位字符串(对齐 py-store store.get_profile) */
200
+ getProfile() {
201
+ return schema.getProfile();
202
+ }
203
+
204
+ /** text2query 便捷上下文(进入设档、退出恢复;同 scopedRoles 的 token-set/reset) */
205
+ async text2query(fn) {
206
+ return text2query(fn);
207
+ }
208
+
164
209
  /** 设置数据源连接映射(多后端路由;对齐 py-store store.set_connections) */
165
210
  setConnections(connections) {
166
211
  return datasource.setConnections(connections);
@@ -191,6 +236,8 @@ class Store {
191
236
 
192
237
  /** 自定义权限错误(实例可被 store.PermissionError 捕获) */
193
238
  Store.prototype.PermissionError = permission.PermissionError;
239
+ /** 档位拒绝错误(实例可被 store.ProfileViolation 捕获;权限错误另见 PermissionError) */
240
+ Store.prototype.ProfileViolation = crud.ProfileViolation;
194
241
  /** 原生 SQL 入口错误(实例可被 store.RawSqlError 捕获) */
195
242
  Store.prototype.RawSqlError = datasource.RawSqlError;
196
243
 
@@ -283,7 +330,9 @@ module.exports = {
283
330
  init,
284
331
  store,
285
332
  Store,
333
+ text2query,
286
334
  PermissionError: permission.PermissionError,
335
+ ProfileViolation: crud.ProfileViolation,
287
336
  PushdownUnsupportedError: datasource.PushdownUnsupportedError,
288
337
  RawSqlError: datasource.RawSqlError,
289
338
  datasource,
package/src/schema.js CHANGED
@@ -115,9 +115,35 @@ function requireContext() {
115
115
  return core.requireContext();
116
116
  }
117
117
 
118
+ /**
119
+ * 设置查询档位:`'standard'`(默认,功能最大化 + 跨 DB 对齐)/
120
+ * `'text2query'`(功能收缩 + 硬限制)
121
+ *
122
+ * 判决唯一在 core;未知档位由 core 抛错(禁静默回落到默认档)。
123
+ */
124
+ function setProfile(profile) {
125
+ core.setProfile(profile);
126
+ }
127
+
128
+ /** 当前档位字符串(`'standard'` / `'text2query'`;对齐 py_store.schema.get_profile) */
129
+ function getProfile() {
130
+ return core.profile();
131
+ }
132
+
118
133
  /** 取 asyncFn 计算列实现(fnRef 缺省 = 计算列 key 名) */
119
134
  function getAsyncFn(fnRef) {
120
135
  return _asyncFns[fnRef];
121
136
  }
122
137
 
123
- module.exports = { core, register, get, has, list, setRequireContext, requireContext, getAsyncFn };
138
+ module.exports = {
139
+ core,
140
+ register,
141
+ get,
142
+ has,
143
+ list,
144
+ setRequireContext,
145
+ requireContext,
146
+ setProfile,
147
+ getProfile,
148
+ getAsyncFn,
149
+ };