nodejs-store 2.1.0 → 2.3.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 +1 -1
- package/src/crud/exec.js +55 -1
- package/src/crud/index.js +2 -1
- package/src/crud/query.js +33 -3
- package/src/ddl.js +11 -3
- package/src/index.js +49 -0
- package/src/schema.js +27 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nodejs-store",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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
|
-
/**
|
|
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 {
|
|
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 = {
|
|
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
|
-
* -
|
|
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]]
|
|
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 = {
|
|
138
|
+
module.exports = {
|
|
139
|
+
core,
|
|
140
|
+
register,
|
|
141
|
+
get,
|
|
142
|
+
has,
|
|
143
|
+
list,
|
|
144
|
+
setRequireContext,
|
|
145
|
+
requireContext,
|
|
146
|
+
setProfile,
|
|
147
|
+
getProfile,
|
|
148
|
+
getAsyncFn,
|
|
149
|
+
};
|