nodejs-store 2.6.0 → 3.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/src/ddl.js CHANGED
@@ -186,4 +186,185 @@ function generate(backend, names) {
186
186
  return blocks.join('\n\n');
187
187
  }
188
188
 
189
- module.exports = { generate };
189
+ // ══════════════════════════════════════════════════════════════════
190
+ // 声明式 schema 迁移(阶段 4;设计见 common-store/迁移设计文档-阶段4.md)
191
+ // 与 py_store/ddl.py 的 migration 段逐字节对齐(parity 锚:tests/migration.test.js)。
192
+ // ══════════════════════════════════════════════════════════════════
193
+
194
+ /** 类型放宽映射(首批):值域安全扩大的单向变更;跨大类不在映射内 → Err */
195
+ const WIDEN = {
196
+ int: ['long', 'float', 'double'],
197
+ long: ['float', 'double'],
198
+ float: ['double'],
199
+ };
200
+
201
+ /** 字段 def → 列 SQL 类型(与 columns 同源;object/array → JSON 文本列) */
202
+ function fieldSqlType(backend, fdef) {
203
+ const i = idx(backend);
204
+ const ftype = declaredType(fdef);
205
+ if (NON_COLUMN.includes(ftype)) return JSON_TYPE[i];
206
+ if (!Object.prototype.hasOwnProperty.call(TYPES, ftype)) {
207
+ throw new Error(
208
+ `迁移生成:字段类型 ${JSON.stringify(ftype)} 未知(支持 ${Object.keys(TYPES).sort()})`);
209
+ }
210
+ return TYPES[ftype][i];
211
+ }
212
+
213
+ /** default 字面量 → SQL 文本(仅 JSON 标量;SQLite 禁非常量表达式 DEFAULT) */
214
+ function sqlLiteral(v) {
215
+ if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE';
216
+ if (typeof v === 'number') return String(v);
217
+ if (typeof v === 'string') return `'${v.replace(/'/g, "''")}'`;
218
+ throw new Error(`迁移生成:default 仅支持 JSON 标量字面量,收到 ${typeof v}`);
219
+ }
220
+
221
+ /** 主 def → 归档表 def(列 = 主列 + deletedAt;剔除 _id 自增策略;indexes 继承) */
222
+ function archiveDefnOf(defn) {
223
+ const fields = { ...(defn.fields || {}) };
224
+ fields.deletedAt = { type: 'number' };
225
+ if (fields._id && typeof fields._id === 'object') {
226
+ const { strategy, ...rest } = fields._id;
227
+ fields._id = rest;
228
+ }
229
+ return {
230
+ ...defn,
231
+ name: `${defn.name}Deleted`,
232
+ collection: `${defn.collection || defn.name}_deleted`,
233
+ fields,
234
+ timestamps: false,
235
+ _isArchive: true,
236
+ };
237
+ }
238
+
239
+ /** 新旧 schema def 对比 → { changes, errors }(后端无关,纯函数;白名单外记入 errors) */
240
+ function diffDefs(oldDefn, newDefn) {
241
+ const changes = [];
242
+ const errors = [];
243
+ const oldFields = (oldDefn && oldDefn.fields) || {};
244
+ const newFields = newDefn.fields || {};
245
+
246
+ if (oldDefn === null || oldDefn === undefined) {
247
+ return { changes: [{ op: 'addTable', defn: newDefn }], errors };
248
+ }
249
+
250
+ for (const [name, nf] of Object.entries(newFields)) {
251
+ if (name === '_id') {
252
+ const of = oldFields._id;
253
+ if (of !== undefined && declaredType(of) !== declaredType(nf)) {
254
+ errors.push(`_id 主键类型变更不支持(${declaredType(of)} → ${declaredType(nf)})`);
255
+ }
256
+ const oStrat = of && typeof of === 'object' ? of.strategy : undefined;
257
+ const nStrat = nf && typeof nf === 'object' ? nf.strategy : undefined;
258
+ if (oStrat !== nStrat) {
259
+ errors.push(`_id 主键策略变更不支持(${JSON.stringify(oStrat)} → ${JSON.stringify(nStrat)})`);
260
+ }
261
+ continue;
262
+ }
263
+ const nfType = declaredType(nf);
264
+ if (!(name in oldFields)) {
265
+ if (!Object.prototype.hasOwnProperty.call(TYPES, nfType) && !NON_COLUMN.includes(nfType)) {
266
+ errors.push(`新列 "${name}" 类型 ${JSON.stringify(nfType)} 未知`);
267
+ continue;
268
+ }
269
+ changes.push({
270
+ op: 'addColumn', name,
271
+ field: nf && typeof nf === 'object' ? nf : { type: nf },
272
+ });
273
+ continue;
274
+ }
275
+ const of = oldFields[name];
276
+ const ofType = declaredType(of);
277
+ if (ofType === nfType) continue; // 恒等:不上报
278
+ if (nfType && (WIDEN[ofType] || []).includes(nfType)) {
279
+ changes.push({ op: 'widenColumn', name, from: ofType, to: nfType });
280
+ } else {
281
+ errors.push(
282
+ `字段 "${name}" 类型 ${JSON.stringify(ofType)} → ${JSON.stringify(nfType)} 非放宽变更`
283
+ + '(首批仅支持单向放宽:int→long/float/double、long→float/double、float→double)');
284
+ }
285
+ }
286
+ for (const name of Object.keys(oldFields)) {
287
+ if (!(name in newFields)) {
288
+ errors.push(`删除字段 "${name}" 不支持(破坏性变更;请显式走数据迁移脚本)`);
289
+ }
290
+ }
291
+
292
+ const indexKeys = (defn) => (defn.indexes || [])
293
+ .filter((ix) => ix && typeof ix === 'object' && ix.keys
294
+ && typeof ix.keys === 'object' && Object.keys(ix.keys).length);
295
+ const oldIdx = indexKeys(oldDefn);
296
+ for (const ix of indexKeys(newDefn)) {
297
+ if (!oldIdx.some((o) => JSON.stringify(o) === JSON.stringify(ix))) {
298
+ changes.push({ op: 'addIndex', index: ix });
299
+ }
300
+ }
301
+
302
+ const oldColl = oldDefn.collection || oldDefn.name;
303
+ const newColl = newDefn.collection || newDefn.name;
304
+ if (oldColl !== newColl) {
305
+ errors.push(`collection 改名不支持(${JSON.stringify(oldColl)} → ${JSON.stringify(newColl)};破坏性变更)`);
306
+ }
307
+ return { changes, errors };
308
+ }
309
+
310
+ /** 新旧 schema def → 迁移 SQL 文本列表(per-dialect;只产文本、不执行) */
311
+ function generateMigration(backend, oldDefn, newDefn) {
312
+ if (!BACKENDS.includes(backend)) {
313
+ throw new Error(`迁移生成:不支持的后端 ${JSON.stringify(backend)}(支持 ${BACKENDS.join('/')})`);
314
+ }
315
+ const plan = diffDefs(oldDefn, newDefn);
316
+ if (plan.errors.length) {
317
+ throw new Error(
318
+ 'MIGRATION_UNSUPPORTED: ' + plan.errors.join(';')
319
+ + '(首批白名单:加表/加列/类型放宽/加索引;破坏性变更请走显式数据迁移脚本)');
320
+ }
321
+
322
+ const table = newDefn.collection || newDefn.name;
323
+ const stmts = [];
324
+ const order = { addColumn: 0, widenColumn: 1, addIndex: 2 };
325
+ for (const ch of [...plan.changes].sort((a, b) => (order[a.op] ?? 9) - (order[b.op] ?? 9))) {
326
+ if (ch.op === 'addTable') {
327
+ stmts.push(createTable(newDefn, backend));
328
+ stmts.push(...indexStmts(newDefn, backend));
329
+ const arch = archiveDefnOf(newDefn);
330
+ stmts.push(createTable(arch, backend));
331
+ stmts.push(...indexStmts(arch, backend));
332
+ } else if (ch.op === 'addColumn') {
333
+ const ftype = declaredType(ch.field);
334
+ const colType = fieldSqlType(backend, ch.field);
335
+ const deflt = ch.field && typeof ch.field === 'object' ? ch.field.default : undefined;
336
+ let colSql = `${q(backend, ch.name)} ${colType}`;
337
+ if (deflt !== undefined && deflt !== null) {
338
+ if (NON_COLUMN.includes(ftype)) {
339
+ throw new Error(
340
+ `MIGRATION_UNSUPPORTED: 新列 "${ch.name}"(object/array JSON 列)不支持 DEFAULT`
341
+ + '(存量行缺失语义由 __present 哨兵表达;default 仅影响新写入)');
342
+ }
343
+ colSql += ` DEFAULT ${sqlLiteral(deflt)}`;
344
+ }
345
+ stmts.push(`ALTER TABLE ${q(backend, table)} ADD COLUMN ${colSql}`);
346
+ stmts.push(`ALTER TABLE ${q(backend, `${table}_deleted`)} ADD COLUMN ${colSql}`);
347
+ } else if (ch.op === 'widenColumn') {
348
+ const colType = fieldSqlType(backend, newDefn.fields[ch.name]);
349
+ if (backend === 'sqlite') {
350
+ throw new Error(
351
+ 'MIGRATION_UNSUPPORTED: SQLite 不支持类型变更 '
352
+ + `(${ch.from} → ${ch.to} 需重建表);加列/加索引/加表已支持`);
353
+ }
354
+ if (backend === 'mysql') {
355
+ stmts.push(`ALTER TABLE ${q(backend, table)} MODIFY COLUMN ${q(backend, ch.name)} ${colType}`);
356
+ } else {
357
+ stmts.push(
358
+ `ALTER TABLE ${q(backend, table)} ALTER COLUMN ${q(backend, ch.name)} `
359
+ + `TYPE ${colType} USING ${q(backend, ch.name)}::${colType}`);
360
+ }
361
+ } else if (ch.op === 'addIndex') {
362
+ stmts.push(...indexStmts({ collection: table, indexes: [ch.index] }, backend));
363
+ } else {
364
+ throw new Error(`MIGRATION_UNSUPPORTED: 未知变更 ${ch.op}`);
365
+ }
366
+ }
367
+ return stmts;
368
+ }
369
+
370
+ module.exports = { generate, diffDefs, generateMigration };
package/src/feedback.js CHANGED
@@ -23,6 +23,11 @@ function setSink(fn) {
23
23
  _sink = typeof fn === 'function' ? fn : null;
24
24
  }
25
25
 
26
+ /** 当前 sink(无则 null)——供接管方(如 ask 编排器)保存/恢复现场 */
27
+ function getSink() {
28
+ return _sink;
29
+ }
30
+
26
31
  /** 产出一条反馈事件:有 sink 回调之;否则打印 stderr(允许拦截,禁止静默) */
27
32
  function emit(event) {
28
33
  const e = event || {};
@@ -36,4 +41,4 @@ function emit(event) {
36
41
  );
37
42
  }
38
43
 
39
- module.exports = { setSink, emit };
44
+ module.exports = { setSink, getSink, emit };
package/src/index.js CHANGED
@@ -21,8 +21,7 @@
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
+ const ask = require('./ask');
26
25
  const crud = require('./crud');
27
26
  const datasource = require('./datasource');
28
27
  const { Session, NonAtomicWriteError } = require('./datasource');
@@ -30,32 +29,12 @@ const ddl = require('./ddl');
30
29
  const executors = require('./executors');
31
30
  const feedback = require('./feedback');
32
31
  const introspect = require('./introspect');
32
+ const llm = require('./llm');
33
33
  const permission = require('./permission');
34
+ const { text2query } = require('./profile');
34
35
  const schema = require('./schema');
35
36
  const { syncSchema } = require('./sync');
36
-
37
- /** 档位 AsyncLocalStorage:记录「进入 text2query 前的原档」,供退出恢复(嵌套安全) */
38
- const _profileAls = new AsyncLocalStorage();
39
-
40
- /**
41
- * 以 text2query 档执行(功能收缩 + 硬限制),退出恢复原档位。
42
- *
43
- * AI 问数链路入口;与 permission.scopedRoles 同构(token-set/reset,嵌套安全)。
44
- * 档位是 core 进程级状态(非本 ALS 隔离),ALS 仅记录「进入时的原档」以便正确恢复,
45
- * 使异步 / 嵌套调用各自回到自己进入前的档位。进入档位即等效强制携带用户上下文
46
- * (core `ensureProfileCtx`,见执行文档 §4.2)。
47
- */
48
- async function text2query(fn) {
49
- const prev = schema.getProfile();
50
- schema.setProfile('text2query');
51
- return _profileAls.run(prev, async () => {
52
- try {
53
- return await fn();
54
- } finally {
55
- schema.setProfile(prev);
56
- }
57
- });
58
- }
37
+ const workflow = require('./workflow');
59
38
 
60
39
  class Store {
61
40
  // ── Schema 管理 ──
@@ -233,6 +212,30 @@ class Store {
233
212
  return ddl.generate(backend, names);
234
213
  }
235
214
 
215
+ // ── 工作流编排(首批:线性 + when 守卫 + fail-fast;见 workflow.js 与设计文档)──
216
+ /** 注册工作流定义(注册即静态校验,白名单外显式 Err 含 WORKFLOW_UNSUPPORTED) */
217
+ registerWorkflow(defn) {
218
+ return workflow.register(defn);
219
+ }
220
+
221
+ /** 全部可见工作流名(read 白名单过滤) */
222
+ workflows(ctx) {
223
+ return workflow.list(ctx);
224
+ }
225
+
226
+ /** 按名取工作流定义(read 白名单过滤;不可见与不存在同形——防枚举) */
227
+ getWorkflow(name, ctx) {
228
+ return workflow.get(name, ctx);
229
+ }
230
+
231
+ /**
232
+ * 触发工作流 → 完整 run 文档
233
+ * (终态 failed/rejected 不抛错,以 run.status + error 表达;dryRun 下 mutation/fail 记 wouldRun)
234
+ */
235
+ async runWorkflow(name, input, opts) {
236
+ return workflow.run(name, input ?? null, opts);
237
+ }
238
+
236
239
  // ── 底层工具(调试/高级用法) ──
237
240
  /** 解析 GQL 并构建 pipeline,返回 `{tokens, ast, pipeline, projection}` */
238
241
  buildPipeline(gql, params) {
@@ -273,6 +276,23 @@ class Store {
273
276
  return text2query(fn);
274
277
  }
275
278
 
279
+ // ── AI 问数(L1,对齐 py-store store.ask / store.describe_for_ai)──
280
+ /**
281
+ * AI 问数唯一入口(LLM 输出永远当不可信输入;护栏面服务端硬编码,详见 ask.js)
282
+ *
283
+ * @param {string} question 自然语言问题
284
+ * @param {{llm: (string|Function), ctx: object, maxRetries?: number, knowledge?: string}} opts
285
+ * @returns {Promise<ask.AskResult>}
286
+ */
287
+ ask(question, opts) {
288
+ return ask.ask(question, opts);
289
+ }
290
+
291
+ /** 输出 LLM 可读的 schema 摘要(权限过滤后的紧凑 JSON 数组;详见 ask.js) */
292
+ describeForAi(ctx = null) {
293
+ return ask.describeForAi(ctx);
294
+ }
295
+
276
296
  /** 设置数据源连接映射(多后端路由;对齐 py-store store.set_connections) */
277
297
  setConnections(connections) {
278
298
  return datasource.setConnections(connections);
@@ -299,6 +319,44 @@ class Store {
299
319
  async runAsInternal(fn) {
300
320
  return permission.runAsInternal(fn);
301
321
  }
322
+
323
+ // ── RBAC 动态策略(判决唯一在 core;本层仅透传配置与查询面) ──
324
+ setRbac(policy) {
325
+ return permission.setRbac(policy);
326
+ }
327
+
328
+ rbacEnabled() {
329
+ return permission.rbacEnabled();
330
+ }
331
+
332
+ rbacCan(model, action, ctx) {
333
+ return permission.rbacCan(model, action, ctx);
334
+ }
335
+
336
+ rbacReadableFields(model, ctx) {
337
+ return permission.rbacReadableFields(model, ctx);
338
+ }
339
+
340
+ rbacWritableFields(model, ctx) {
341
+ return permission.rbacWritableFields(model, ctx);
342
+ }
343
+
344
+ rbacRowCondition(model, action, ctx) {
345
+ return permission.rbacRowCondition(model, action, ctx);
346
+ }
347
+
348
+ // ── 角色清单与未配置姿态(清单化语义,判决唯一在 core;本层仅透传配置) ──
349
+ setExemptRoles(roles) {
350
+ return permission.setExemptRoles(roles);
351
+ }
352
+
353
+ setDenyWriteRoles(roles) {
354
+ return permission.setDenyWriteRoles(roles);
355
+ }
356
+
357
+ setUnconfiguredPolicy(policy) {
358
+ return permission.setUnconfiguredPolicy(policy);
359
+ }
302
360
  }
303
361
 
304
362
  /** 自定义权限错误(实例可被 store.PermissionError 捕获) */
@@ -309,6 +367,10 @@ Store.prototype.ProfileViolation = crud.ProfileViolation;
309
367
  Store.prototype.RawSqlError = datasource.RawSqlError;
310
368
  /** 原生 Mongo 命令入口错误(实例可被 store.NativeCommandError 捕获) */
311
369
  Store.prototype.NativeCommandError = datasource.NativeCommandError;
370
+ /** AI 问数重试耗尽(实例可被 store.AskExhausted 捕获,携带 .attempts / .events 全轨迹) */
371
+ Store.prototype.AskExhausted = ask.AskExhausted;
372
+ /** AI 问数成功结果(store.ask 的返回类型) */
373
+ Store.prototype.AskResult = ask.AskResult;
312
374
 
313
375
  const store = new Store();
314
376
 
@@ -416,4 +478,12 @@ module.exports = {
416
478
  feedback,
417
479
  introspect,
418
480
  syncSchema,
481
+ workflow,
482
+ WorkflowError: workflow.WorkflowError,
483
+ // ── AI 问数(L1):编排器 + schema 摘要 + LLM 插拔注册表(对齐 py-store ask/llm)──
484
+ ask: ask.ask,
485
+ describeForAi: ask.describeForAi,
486
+ AskResult: ask.AskResult,
487
+ AskExhausted: ask.AskExhausted,
488
+ llm,
419
489
  };
package/src/llm.js ADDED
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 插拔式 LLM 注册表 —— 《AI能力接入设计-L1问数档.md》§4.2 决策 D6(M2 nodejs-store parity)
5
+ *
6
+ * 协议(唯一):`async function llm(messages: Array<{role, content}>) => string`
7
+ * nodejs-store 不引入任何 LLM SDK(依赖由宿主应用决定);护栏在 core/Host 不在 LLM,
8
+ * 能力开关只影响首次命中率,不影响正确性。
9
+ *
10
+ * 插拔三件套(对齐 py_store.llm 同名件):
11
+ * - registerLlm() 注册;client 为符合协议的函数(测试注入假 LLM 同走此口);
12
+ * 同名重复注册显式报错(禁静默覆盖)
13
+ * - getLlm() 按名取用;未注册显式报错(禁静默回落默认厂商)
14
+ * - makeOpenaiCompat() OpenAI 兼容通用工厂(Node 18+ 全局 fetch,零依赖):
15
+ * 一个实现覆盖 DeepSeek/OpenAI/Moonshot/SiliconFlow/Ollama 等
16
+ * 兼容端点;Anthropic 原生等特殊协议由应用侧自行 make 后注册,
17
+ * 数据层不内置 SDK。
18
+ *
19
+ * 能力差异收敛为工厂开关(同 py):
20
+ * - jsonMode=false:不传 response_format,退化路径 = prompt 约定 + ask() 严格
21
+ * JSON 解析失败回喂(D3);
22
+ * - effort=null:不传 reasoning_effort(非推理模型或不支持该参数的端点)。
23
+ *
24
+ * 实测接入事实(py 侧原型实证,直接继承;见设计文档 §4.2「三个接入事实」):
25
+ * 1. DeepSeek 网关对默认 UA 断连(curl 同参 200 实证)→ 显式覆盖 User-Agent
26
+ * (fetch 默认 UA 为 `node`,同理覆盖);
27
+ * 2. json_object 模式要求 prompt 含 "json" 字样,缺失时网关 400 → 工厂契约预检,
28
+ * 缺失即抛 llmJsonPromptMissing(禁静默替调用方改写消息面,no-error-masking);
29
+ * 3. 网络层断连/超时 → llmNetworkError;HTTP 4xx/5xx → llmHttpError(携状态码
30
+ * 与响应体片段);空 content(多为推理 token 耗尽 max_tokens)→ llmEmptyContent
31
+ * (携 finish_reason 与推理 token 数,禁静默返回空串)。
32
+ * 三者均以 LlmError(Error 子类,.detail 携结构化对象)抛出,ask() 原样穿透
33
+ * (不回喂、不降级)——对齐 py 侧 RuntimeError(dict) 的「异常本体携带结构化详情」。
34
+ */
35
+
36
+ const _REGISTRY = new Map();
37
+
38
+ /**
39
+ * LLM 客户端自身的结构化失败(网络 / HTTP / 契约预检 / 空 content)
40
+ *
41
+ * .detail 为结构化对象(code 稳定、其余字段如实),message 内嵌序列化详情——
42
+ * 上游不读属性也能看到失败原因(对齐 AskExhausted 的消息形态)。
43
+ */
44
+ class LlmError extends Error {
45
+ constructor(detail) {
46
+ super(`${(detail && detail.code) || 'llmError'}: ${JSON.stringify(detail)}`);
47
+ this.name = 'LlmError';
48
+ this.detail = detail;
49
+ }
50
+ }
51
+
52
+ /** 注册一个 LLM 客户端;同名重复注册显式报错(禁静默覆盖) */
53
+ function registerLlm(name, client) {
54
+ if (typeof name !== 'string' || !name) {
55
+ throw new TypeError('registerLlm(name, client) 需要非空字符串注册名');
56
+ }
57
+ if (typeof client !== 'function') {
58
+ throw new TypeError('LLM 客户端必须是符合协议的函数:async (messages) => string');
59
+ }
60
+ if (_REGISTRY.has(name)) {
61
+ throw new Error(`LLM 客户端重复注册: ${name}`);
62
+ }
63
+ _REGISTRY.set(name, client);
64
+ }
65
+
66
+ /** 按注册名取客户端;未注册显式报错(禁静默回落默认厂商) */
67
+ function getLlm(name) {
68
+ if (!_REGISTRY.has(name)) {
69
+ throw new Error(`LLM 客户端未注册: ${String(name)}(已注册: ${[..._REGISTRY.keys()].sort().join(', ') || '(无)'})`);
70
+ }
71
+ return _REGISTRY.get(name);
72
+ }
73
+
74
+ /**
75
+ * OpenAI 兼容端点通用工厂(返回符合协议的 async llm;Node 18+ 全局 fetch,零依赖)。
76
+ *
77
+ * 失败全部显式抛 LlmError(.detail 结构化),禁静默返回空串——
78
+ * 空 content 的实证成因与防御见模块 docstring 与设计文档 W3。
79
+ */
80
+ function makeOpenaiCompat({
81
+ baseUrl,
82
+ model,
83
+ apiKey,
84
+ jsonMode = true,
85
+ effort = 'low',
86
+ maxTokens = 4096,
87
+ timeoutMs = 90000,
88
+ } = {}) {
89
+ if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
90
+ throw new TypeError('makeOpenaiCompat 需要非空 baseUrl');
91
+ }
92
+ if (typeof model !== 'string' || !model) {
93
+ throw new TypeError('makeOpenaiCompat 需要非空 model');
94
+ }
95
+
96
+ async function llm(messages) {
97
+ const body = { model, messages, max_tokens: maxTokens };
98
+ if (jsonMode) {
99
+ // 契约预检:json_object 要求 prompt 含 "json" 字样(DeepSeek 实测 400 实证)。
100
+ // 缺失显式报错交上游装配修复,禁静默改写调用方消息面(no-error-masking)
101
+ const joined = messages.map((m) => String((m && m.content) ?? '')).join(' ').toLowerCase();
102
+ if (!joined.includes('json')) {
103
+ throw new LlmError({
104
+ code: 'llmJsonPromptMissing',
105
+ model,
106
+ message: "json_mode=true 要求 messages(system/user)中包含 'json' 字样",
107
+ hint: "ask() 的 system 输出契约模板已固定含 'JSON' 措辞;自定义 knowledge 时须保留",
108
+ });
109
+ }
110
+ body.response_format = { type: 'json_object' };
111
+ }
112
+ if (effort !== null && effort !== undefined) {
113
+ body.reasoning_effort = effort;
114
+ }
115
+
116
+ let resp;
117
+ try {
118
+ resp = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
119
+ method: 'POST',
120
+ headers: {
121
+ 'Content-Type': 'application/json',
122
+ Authorization: `Bearer ${apiKey}`,
123
+ // 默认 UA「node」被 DeepSeek 网关断连(py 侧 urllib 同因实证),显式覆盖
124
+ 'User-Agent': 'nodejs-store-ask/0.1',
125
+ },
126
+ body: JSON.stringify(body),
127
+ signal: AbortSignal.timeout(timeoutMs),
128
+ });
129
+ } catch (e) {
130
+ // 网络层断连 / 超时 / 重置,结构化暴露(不吞错、不重试)
131
+ throw new LlmError({ code: 'llmNetworkError', model, detail: String((e && e.message) || e) });
132
+ }
133
+ if (!resp.ok) {
134
+ const text = await resp.text().catch(() => '');
135
+ throw new LlmError({ code: 'llmHttpError', status: resp.status, model, body: String(text).slice(0, 500) });
136
+ }
137
+ const data = await resp.json();
138
+ const choice = (Array.isArray(data.choices) && data.choices[0]) || {};
139
+ const content = choice.message ? choice.message.content : undefined;
140
+ if (!content) {
141
+ throw new LlmError({
142
+ code: 'llmEmptyContent',
143
+ model,
144
+ finish_reason: choice.finish_reason ?? null,
145
+ reasoning_tokens: (data.usage && data.usage.completion_tokens_details)
146
+ ? (data.usage.completion_tokens_details.reasoning_tokens ?? null)
147
+ : null,
148
+ message: 'LLM 返回空 content(多为 max_tokens 被推理耗尽,调大 max_tokens 或降 effort)',
149
+ });
150
+ }
151
+ return content;
152
+ }
153
+
154
+ return llm;
155
+ }
156
+
157
+ module.exports = { LlmError, registerLlm, getLlm, makeOpenaiCompat, _registry: _REGISTRY };
package/src/permission.js CHANGED
@@ -36,6 +36,18 @@ function scopedRoles(roles, fn) {
36
36
  return _als.run(next, () => fn());
37
37
  }
38
38
 
39
+ /**
40
+ * 以完整上下文 ctx 进入临时权限上下文(嵌套安全),执行 fn 后自动恢复原上下文。
41
+ *
42
+ * 对齐 py_store.permission.scoped_context;与 scopedRoles 同构,区别在于整体替换
43
+ * ctx(保留调用方原上下文于外层),供 AI 问数(ask)等把服务端构造的用户上下文
44
+ * 显式注入执行面的场景——不用「setContext + finally 清空」写法(嵌套时误清外层,
45
+ * 静默失守方向)。
46
+ */
47
+ function scopedContext(ctx, fn) {
48
+ return _als.run(ctx ?? null, () => fn());
49
+ }
50
+
39
51
  /** 在内部上下文中执行操作(绕过权限检查),结束后自动恢复上下文 */
40
52
  async function runAsInternal(fn) {
41
53
  const prev = getContext();
@@ -76,6 +88,11 @@ function getReadableRelations(schema, ctx) {
76
88
  return core.readableRelations(_model(schema), ctx ?? null);
77
89
  }
78
90
 
91
+ /** 角色可读计算列集(列级白名单;core 未导出该判决的旧绑定上为 undefined) */
92
+ function getReadableComputes(schema, ctx) {
93
+ return core.readableComputes(_model(schema), ctx ?? null);
94
+ }
95
+
79
96
  function getWritableFields(schema, ctx) {
80
97
  return core.writableFields(_model(schema), ctx ?? null);
81
98
  }
@@ -84,6 +101,52 @@ function filterWritableData(schema, ctx, data) {
84
101
  return core.filterWritableData(_model(schema), ctx ?? null, data);
85
102
  }
86
103
 
104
+ // ─── RBAC 动态策略(core 判决;本模块零判决,仅透传,对齐 py_store.permission) ──
105
+
106
+ /** 注入/清除 RBAC 策略。object = 注入(解析失败 core 抛错);null = 清除关闭 */
107
+ function setRbac(policy) {
108
+ return core.setRbac(policy ?? null);
109
+ }
110
+
111
+ /** RBAC 策略是否已注入 */
112
+ function rbacEnabled() {
113
+ return core.rbacEnabled();
114
+ }
115
+
116
+ /** RBAC 动作判决:action ∈ {read, insert, update, remove};RBAC 不介入 → true */
117
+ function rbacCan(model, action, ctx) {
118
+ return core.rbacCan(_model(model), action, ctx ?? null);
119
+ }
120
+
121
+ /** RBAC 叠加后的可读字段集(静态 ∩ readFields);无 ctx → null 不裁剪 */
122
+ function rbacReadableFields(model, ctx) {
123
+ return core.rbacReadableFields(_model(model), ctx ?? null);
124
+ }
125
+
126
+ /** RBAC 叠加后的可写字段集(静态 ∩ writeFields);无 ctx → null 不裁剪 */
127
+ function rbacWritableFields(model, ctx) {
128
+ return core.rbacWritableFields(_model(model), ctx ?? null);
129
+ }
130
+
131
+ /** RBAC 行级条件(ownerOnly/condition 的 OR 合并体);action ∈ {read, update, remove} */
132
+ function rbacRowCondition(model, action, ctx) {
133
+ return core.rbacRowCondition(_model(model), action, ctx ?? null);
134
+ }
135
+
136
+ // ── 角色清单与未配置姿态(清单化语义,判决唯一在 core;本层仅透传) ──
137
+
138
+ function setExemptRoles(roles) {
139
+ return core.setExemptRoles(roles);
140
+ }
141
+
142
+ function setDenyWriteRoles(roles) {
143
+ return core.setDenyWriteRoles(roles);
144
+ }
145
+
146
+ function setUnconfiguredPolicy(policy) {
147
+ return core.setUnconfiguredPolicy(policy);
148
+ }
149
+
87
150
  // ─── 自定义错误 ──────────────────────────────────────────────
88
151
 
89
152
  class PermissionError extends Error {
@@ -96,8 +159,10 @@ class PermissionError extends Error {
96
159
 
97
160
  module.exports = {
98
161
  setContext,
162
+ _als,
99
163
  getContext,
100
164
  scopedRoles,
165
+ scopedContext,
101
166
  runAsInternal,
102
167
  canReadSchema,
103
168
  canWriteSchema,
@@ -105,7 +170,17 @@ module.exports = {
105
170
  mergeOwnerCondition,
106
171
  getReadableFields,
107
172
  getReadableRelations,
173
+ getReadableComputes,
108
174
  getWritableFields,
109
175
  filterWritableData,
176
+ setRbac,
177
+ rbacEnabled,
178
+ rbacCan,
179
+ rbacReadableFields,
180
+ rbacWritableFields,
181
+ rbacRowCondition,
182
+ setExemptRoles,
183
+ setDenyWriteRoles,
184
+ setUnconfiguredPolicy,
110
185
  PermissionError,
111
186
  };
package/src/profile.js ADDED
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 查询档位上下文 —— text2query 便捷上下文
5
+ *
6
+ * 自 index.js 提取为独立模块(ask.js 需硬编码进入 text2query 档,直接依赖本模块
7
+ * 而非包入口,避免循环 require);对 index.js 的导出面零变化(index.js re-export)。
8
+ *
9
+ * 档位 AsyncLocalStorage:记录「进入 text2query 前的原档」,供退出恢复(嵌套安全)。
10
+ * 与 permission.scopedRoles 同构(token-set/reset,嵌套安全)。
11
+ * 档位是 core 进程级状态(非本 ALS 隔离),ALS 仅记录「进入时的原档」以便正确恢复,
12
+ * 使异步 / 嵌套调用各自回到自己进入前的档位。进入档位即等效强制携带用户上下文
13
+ * (core `ensureProfileCtx`,见执行文档 §4.2)。
14
+ */
15
+
16
+ const { AsyncLocalStorage } = require('node:async_hooks');
17
+
18
+ const { getProfile, setProfile } = require('./schema');
19
+
20
+ const _profileAls = new AsyncLocalStorage();
21
+
22
+ /** 以 text2query 档执行 fn,退出恢复原档位(AI 问数链路入口) */
23
+ async function text2query(fn) {
24
+ const prev = getProfile();
25
+ setProfile('text2query');
26
+ return _profileAls.run(prev, async () => {
27
+ try {
28
+ return await fn();
29
+ } finally {
30
+ setProfile(prev);
31
+ }
32
+ });
33
+ }
34
+
35
+ module.exports = { text2query, _profileAls };