@republicroad/zen-udf 0.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.
Files changed (40) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +80 -0
  3. package/docs/naming.md +40 -0
  4. package/package.json +24 -0
  5. package/src/context-hardening.test.ts +93 -0
  6. package/src/contrib/crypto.test.ts +60 -0
  7. package/src/contrib/crypto.ts +74 -0
  8. package/src/contrib/custom-list-query.ts +51 -0
  9. package/src/contrib/debug.ts +42 -0
  10. package/src/contrib/debugui.test.ts +10 -0
  11. package/src/contrib/debugui.ts +27 -0
  12. package/src/contrib/http-guard.test.ts +116 -0
  13. package/src/contrib/http.test.ts +230 -0
  14. package/src/contrib/http.ts +283 -0
  15. package/src/contrib/ip-location.ts +82 -0
  16. package/src/contrib/legacy-graph-acceptance.test.ts +67 -0
  17. package/src/contrib/rate-store-conformance.ts +68 -0
  18. package/src/contrib/rate-store.test.ts +30 -0
  19. package/src/contrib/rate-window.ts +195 -0
  20. package/src/contrib/rebuilt-functions.test.ts +60 -0
  21. package/src/contrib/roster.test.ts +27 -0
  22. package/src/contrib/roster.ts +38 -0
  23. package/src/decision-cache.test.ts +122 -0
  24. package/src/decision-cache.ts +104 -0
  25. package/src/decision-runtime.test.ts +154 -0
  26. package/src/engine-cache-semantics.test.ts +87 -0
  27. package/src/engine.ts +474 -0
  28. package/src/exec-context.test.ts +52 -0
  29. package/src/exec-context.ts +32 -0
  30. package/src/execution-spec.test.ts +173 -0
  31. package/src/index.ts +43 -0
  32. package/src/limiter.ts +70 -0
  33. package/src/reference.ts +34 -0
  34. package/src/register.test.ts +60 -0
  35. package/src/register.ts +515 -0
  36. package/src/roster.test.ts +96 -0
  37. package/src/roster.ts +129 -0
  38. package/src/sanitize.test.ts +65 -0
  39. package/src/udf-pack.test.ts +107 -0
  40. package/src/udf-trace.test.ts +137 -0
package/src/engine.ts ADDED
@@ -0,0 +1,474 @@
1
+ import type {
2
+ ZenDecision,
3
+ ZenEngineHandlerRequest,
4
+ ZenEngineHandlerResponse,
5
+ ZenEngineOptions,
6
+ ZenEvaluateOptions,
7
+ } from '@gorules/zen-engine';
8
+ import { ZenDecisionContent, ZenEngine, evaluateExpressionSync } from '@gorules/zen-engine';
9
+
10
+ import { type CacheMetricsSnapshot, DecisionCache } from './decision-cache.ts';
11
+ import { EXEC_CONTEXT_INPUT_KEY, type ExecContext, getExecContext, runWithExecContext } from './exec-context.ts';
12
+ import { type ConcurrencyLimiter } from './limiter.ts';
13
+ import { type UdfRegistry, globalUdfRegistry } from './register.ts';
14
+
15
+ const CUSTOM_HANDLER_META = '__meta__';
16
+
17
+ interface ExprAstItem {
18
+ id: string;
19
+ key: string;
20
+ value: string | string[];
21
+ }
22
+
23
+ /** UDF 函数粒度执行轨迹(执行规范 §6.6):经 customHandler 的 traceData 下发 */
24
+ export interface UdfTrace {
25
+ /** 表达式实例 key(customNode 输出字段) */
26
+ key: string;
27
+ name: string;
28
+ micros: number;
29
+ /** 违例/异常码:INVALID_PARAM / UDF_TIMEOUT / INVALID_RESULT / UDF_NOT_FOUND / UDF_ERROR */
30
+ code?: string;
31
+ issues?: string[];
32
+ }
33
+
34
+ interface EvaluateResponse {
35
+ performance: string;
36
+ result: unknown;
37
+ trace?: unknown;
38
+ }
39
+
40
+ /** DecisionRuntime 构造项:zen-engine 原生 options + 实例级 UDF 注册表 + L1 缓存配置 */
41
+ export interface DecisionRuntimeOptions extends ZenEngineOptions {
42
+ /** 缺省回落 globalUdfRegistry(配合 `@republicroad/zen-udf` 根导入的 reference 装载) */
43
+ registry?: UdfRegistry;
44
+ /** L1 决策缓存容量(条目数),缺省 500 */
45
+ cacheCapacity?: number;
46
+ /** 缓存指标 sink(verdict 接 Prometheus 用),每次读写后回调快照 */
47
+ metricsSink?: (snapshot: CacheMetricsSnapshot) => void;
48
+ /** per-tenant 并发闸(执行规范 §6.3);缺省不限并发 */
49
+ limiter?: ConcurrencyLimiter;
50
+ /**
51
+ * 返回值契约档位(执行规范 §6.5,宿主裁决 D5):缺省 `warn`——违例计入 traceData、
52
+ * 结果原样下发;`enforce` 把违例结果替换为 INVALID_RESULT 结构化错误。
53
+ */
54
+ resultValidation?: 'off' | 'warn' | 'enforce';
55
+ /** UDF 错误消息脱敏器(执行规范 §6.7);缺省内置规则(路径/敏感环境值替换为占位) */
56
+ sanitizer?: (message: string) => string;
57
+ }
58
+
59
+ interface GraphNode {
60
+ type?: string;
61
+ name?: string;
62
+ content?: {
63
+ config?: Record<string, unknown>;
64
+ };
65
+ [key: string]: unknown;
66
+ }
67
+
68
+ interface GraphContent {
69
+ id?: string;
70
+ metadata?: Record<string, unknown>;
71
+ nodes: GraphNode[];
72
+ [key: string]: unknown;
73
+ }
74
+
75
+ function evaluateExpressionSafe(expr: string, input?: unknown): unknown {
76
+ try {
77
+ return evaluateExpressionSync(expr, input);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /** 内置脱敏规则(执行规范 §6.7):绝对路径占位 + 敏感名环境变量值替换为 [ENV_NAME] */
84
+ const sanitizeErrorDefault = (message: string): string => {
85
+ let out = message.replace(/(?:[A-Za-z]:)?(?:[/\\][^\s'"`]+)+/g, '[path]');
86
+ for (const [name, value] of Object.entries(process.env)) {
87
+ if (/(secret|token|password|key|credential)/i.test(name) && value && value.length >= 4) {
88
+ out = out.split(value).join(`[${name.toUpperCase()}]`);
89
+ }
90
+ }
91
+ return out;
92
+ };
93
+
94
+ class DecisionRuntime {
95
+ static CUSTOM_HANDLER_META = CUSTOM_HANDLER_META;
96
+
97
+ engine: ZenEngine;
98
+ options: DecisionRuntimeOptions;
99
+ /** 实例级 UDF 注册表(缺省回落 globalUdfRegistry;多实例互不污染) */
100
+ registry: UdfRegistry;
101
+ /** L1 决策缓存(键含租户与 rev,见 docs/design/zen-udf-multi-tenant.md §3) */
102
+ cache: DecisionCache;
103
+ /** per-tenant 并发闸(缺省 undefined = 不限并发) */
104
+ limiter?: ConcurrencyLimiter;
105
+ /** 返回值契约档位(缺省 warn,宿主裁决 D5) */
106
+ resultValidation: 'off' | 'warn' | 'enforce';
107
+ /** 错误消息脱敏器 */
108
+ sanitizer: (message: string) => string;
109
+
110
+ constructor(options: DecisionRuntimeOptions = {}) {
111
+ this.registry = options.registry ?? globalUdfRegistry;
112
+ this.cache = new DecisionCache({ capacity: options.cacheCapacity, metricsSink: options.metricsSink });
113
+ this.limiter = options.limiter;
114
+ this.resultValidation = options.resultValidation ?? 'warn';
115
+ this.sanitizer = options.sanitizer ?? sanitizeErrorDefault;
116
+ if (options.customHandler == null) {
117
+ options.customHandler = (request) => this.handleCustomNode(request);
118
+ }
119
+ this.options = options;
120
+ this.engine = new ZenEngine(this.options);
121
+ }
122
+
123
+ createDecision(content: string | object): ZenDecision {
124
+ const contentObj = typeof content === 'string' ? JSON.parse(content) : content;
125
+ const enhanced = this.graphAddons(contentObj);
126
+ const decisionContent = new ZenDecisionContent(enhanced);
127
+ return this.engine.createDecision(decisionContent);
128
+ }
129
+
130
+ /**
131
+ * L1 缓存键:`${tenantId}:${key}@${rev}`——不可变版本键,与 verdict `modelId:v{rev}` 对齐。
132
+ * tenantExempt 时租户段为 'single';无任何租户上下文时拒绝(fail closed)。
133
+ */
134
+ private composeCacheKey(key: string, rev?: string): string {
135
+ const ctx = getExecContext();
136
+ const tenant = ctx?.tenantId ?? (ctx?.tenantExempt ? 'single' : '');
137
+ if (!tenant) {
138
+ throw new Error('[zen-udf] cache operations require exec context tenantId (or tenantExempt)');
139
+ }
140
+ return tenant + ':' + key + '@' + (rev ?? 'latest');
141
+ }
142
+
143
+ private buildAndCache(cacheKey: string, content: string | object): ZenDecision {
144
+ const startedAt = this.cache.markBuildStart();
145
+ const decision = this.createDecision(content);
146
+ this.cache.markBuildEnd(startedAt);
147
+ this.cache.set(cacheKey, { decision, content });
148
+ return decision;
149
+ }
150
+
151
+ createDecisionWithCacheKey(key: string, content: string | object, rev?: string): ZenDecision {
152
+ const cacheKey = this.composeCacheKey(key, rev);
153
+ if (this.cache.has(cacheKey)) {
154
+ throw new Error(
155
+ 'rule key:' + key + ' is existed, if confirm to overwrite this key, please use updateDecisionWithCacheKey',
156
+ );
157
+ }
158
+ return this.buildAndCache(cacheKey, content);
159
+ }
160
+
161
+ updateDecisionWithCacheKey(key: string, content: string | object, rev?: string): ZenDecision {
162
+ const cacheKey = this.composeCacheKey(key, rev);
163
+ if (!this.cache.has(cacheKey)) {
164
+ throw new Error('rule key:' + key + ' is not existed, please use createDecisionWithCacheKey');
165
+ }
166
+ return this.buildAndCache(cacheKey, content);
167
+ }
168
+
169
+ deleteDecisionWithCacheKey(key: string, rev?: string): void {
170
+ const cacheKey = this.composeCacheKey(key, rev);
171
+ if (!this.cache.has(cacheKey)) {
172
+ throw new Error('delete failed! rule key:' + key + ' is not existed');
173
+ }
174
+ this.cache.delete(cacheKey);
175
+ }
176
+
177
+ getDecision(key: string, rev?: string): ZenDecision {
178
+ const cacheKey = this.composeCacheKey(key, rev);
179
+ const cached = this.cache.get(cacheKey);
180
+ if (cached) {
181
+ return cached.decision;
182
+ }
183
+ const loader = this.options.loader;
184
+ // zen-engine 2.0:loader 为「函数 | static/fs/zip 对象」四形联合——本仓仅支持同步函数形态
185
+ if (typeof loader !== 'function') {
186
+ throw new Error('decision ' + key + ' not found, please use createDecisionWithCacheKey');
187
+ }
188
+ const decisionContent = loader(key);
189
+ if (decisionContent instanceof Promise) {
190
+ throw new Error('loader returned a Promise; only sync loaders are supported for now');
191
+ }
192
+ const startedAt = this.cache.markBuildStart();
193
+ const decision = this.createDecision(decisionContent);
194
+ this.cache.markBuildEnd(startedAt);
195
+ this.cache.set(cacheKey, { decision, content: decisionContent });
196
+ return decision;
197
+ }
198
+
199
+ getDecisionCache(key: string, rev?: string): ZenDecision | undefined {
200
+ return this.cache.get(this.composeCacheKey(key, rev))?.decision;
201
+ }
202
+
203
+ getContentCache(key: string, rev?: string): unknown {
204
+ return this.cache.get(this.composeCacheKey(key, rev))?.content;
205
+ }
206
+
207
+ /**
208
+ * 多租户安全默认(fail closed):evaluate 入口强制租户上下文。
209
+ * 单租户 CLI/本地场景在 ExecContext 设 tenantExempt: true 显式豁免。
210
+ */
211
+ private static requireTenantContext(): void {
212
+ const ctx = getExecContext();
213
+ if (ctx?.tenantExempt) return;
214
+ if (!ctx?.tenantId) {
215
+ throw new Error(
216
+ '[zen-udf] missing exec context tenantId — wrap the call in runWithExecContext({ tenantId }, fn), or set tenantExempt: true for single-tenant deployments',
217
+ );
218
+ }
219
+ }
220
+
221
+ /**
222
+ * ALS 不跨 zen-engine 的 Rust worker → TSFN 回调边界存活(探针实证),
223
+ * 把当前 ExecContext 以保留键嵌入输入对象,由 handleCustomNode 提取后
224
+ * 重建立上下文。仅对象输入可承载;其余形态按无上下文执行(UDF 内 fail closed)。
225
+ * 嵌入的是冻结副本:防图表达式篡改调用方持有的共享 ctx 对象(加固 §5)。
226
+ */
227
+ private static enrichInputWithExecContext(ctx: unknown): unknown {
228
+ if (ctx === null || typeof ctx !== 'object' || Array.isArray(ctx)) {
229
+ return ctx;
230
+ }
231
+ const execCtx = getExecContext();
232
+ if (!execCtx) {
233
+ return ctx;
234
+ }
235
+ return { ...(ctx as Record<string, unknown>), [EXEC_CONTEXT_INPUT_KEY]: Object.freeze({ ...execCtx }) };
236
+ }
237
+
238
+ evaluate(key: string, ctx: unknown, options?: unknown, rev?: string): Promise<EvaluateResponse> {
239
+ DecisionRuntime.requireTenantContext();
240
+ const decision = this.getDecision(key, rev);
241
+ return decision.evaluate(
242
+ DecisionRuntime.enrichInputWithExecContext(ctx),
243
+ options as ZenEvaluateOptions | null | undefined,
244
+ ) as Promise<EvaluateResponse>;
245
+ }
246
+
247
+ async evaluateAsync(key: string, ctx: unknown, options?: unknown, rev?: string): Promise<EvaluateResponse> {
248
+ DecisionRuntime.requireTenantContext();
249
+ const decision = this.getDecision(key, rev);
250
+ const result = await decision.evaluate(
251
+ DecisionRuntime.enrichInputWithExecContext(ctx),
252
+ options as ZenEvaluateOptions | null | undefined,
253
+ );
254
+ return result as EvaluateResponse;
255
+ }
256
+
257
+ graphAddons(content: GraphContent): object {
258
+ const ruleGraph = JSON.parse(JSON.stringify(content)) as GraphContent;
259
+
260
+ if (!ruleGraph.id) {
261
+ ruleGraph.id =
262
+ typeof globalThis.crypto?.randomUUID === 'function' ? globalThis.crypto.randomUUID() : `decision-${Date.now()}`;
263
+ }
264
+
265
+ const inputNodeName =
266
+ ruleGraph.nodes
267
+ .filter((n) => n.type === 'inputNode')
268
+ .map((n) => n.name)
269
+ .filter(Boolean)[0] ?? '';
270
+
271
+ const ruleId = ruleGraph.id ?? '';
272
+ const ruleMeta = ruleGraph.metadata ?? {};
273
+ (ruleMeta as Record<string, unknown>)['namespace'] = ruleId;
274
+ (ruleMeta as Record<string, unknown>)['inputNode_name'] = inputNodeName;
275
+
276
+ for (const node of ruleGraph.nodes) {
277
+ if (node.type !== 'customNode') continue;
278
+ const config = (node.content?.config ?? {}) as Record<string, unknown>;
279
+
280
+ const chMeta = ((config[CUSTOM_HANDLER_META] as Record<string, unknown>) ??
281
+ (config['meta'] as Record<string, unknown>) ??
282
+ {}) as Record<string, unknown>;
283
+ Object.assign(chMeta, ruleMeta);
284
+ config[CUSTOM_HANDLER_META] = chMeta;
285
+
286
+ if (config['passThrough'] == null) {
287
+ config['passThrough'] = true;
288
+ }
289
+
290
+ const customExpressions = config['expressions'] as ExprAstItem[] | undefined;
291
+ if (customExpressions) {
292
+ const exprAsts: ExprAstItem[] = [];
293
+ for (const funcItem of customExpressions) {
294
+ const item = { ...funcItem };
295
+ item.value = DecisionRuntime.parseOperatorExpr(funcItem.value);
296
+ exprAsts.push(item);
297
+ }
298
+ config['expr_asts'] = exprAsts;
299
+ }
300
+ }
301
+
302
+ return ruleGraph;
303
+ }
304
+
305
+ static parseOperatorExpr(expr: string | string[]): string[] {
306
+ if (Array.isArray(expr)) {
307
+ return expr;
308
+ }
309
+ const pattern = /;;(?=(?:[^"'`]*["'`][^"'`]*["'`])*[^"'`]*$)/;
310
+ const parts = expr.split(pattern).map((s) => s.trim());
311
+ return parts;
312
+ }
313
+
314
+ /** customNode 执行器(实例绑定:经 this.registry 解析 UDF,多运行时互不串扰) */
315
+ private async handleCustomNode(request: ZenEngineHandlerRequest): Promise<ZenEngineHandlerResponse> {
316
+ const node = request.node;
317
+ const exprAsts = (node.config?.['expr_asts'] ?? []) as ExprAstItem[];
318
+ const inputField = (node.config?.['inputField'] as string | null) ?? null;
319
+ const outputPath = (node.config?.['outputPath'] as string | null) ?? null;
320
+ const passThrough = (node.config?.['passThrough'] as boolean | null) ?? null;
321
+ const meta = (node.config?.[CUSTOM_HANDLER_META] as Record<string, unknown>) ?? {};
322
+
323
+ // ExecContext 重建立:ALS 不跨 TSFN 边界,从嵌入输入的保留键恢复(见 evaluate)
324
+ const rawInput = (request.input ?? {}) as Record<string, unknown>;
325
+ const execCtx = rawInput[EXEC_CONTEXT_INPUT_KEY] as ExecContext | undefined;
326
+
327
+ const context: Record<string, unknown> = {
328
+ node_id: node.id,
329
+ [CUSTOM_HANDLER_META]: meta,
330
+ passThrough,
331
+ inputField,
332
+ outputPath,
333
+ };
334
+
335
+ const execute = async (): Promise<ZenEngineHandlerResponse> => {
336
+ // 执行规范 §6.6:UDF 函数粒度轨迹(经 traceData 下发,simulator/verdict 审计共用)
337
+ const traces: UdfTrace[] = [];
338
+ const coroFuncs = exprAsts.map((item) => this.executeExpr(item, request.input, context, traces));
339
+ const resultsArr = await Promise.all(coroFuncs);
340
+ const results: Record<string, unknown> = {};
341
+ exprAsts.forEach((item, i) => {
342
+ results[item.key] = resultsArr[i];
343
+ });
344
+
345
+ if (passThrough && typeof request.input === 'object' && request.input !== null) {
346
+ const input = request.input as Record<string, unknown>;
347
+ for (const key of Object.keys(input)) {
348
+ if (key !== '$nodes' && key !== EXEC_CONTEXT_INPUT_KEY) {
349
+ results[key] = input[key];
350
+ }
351
+ }
352
+ }
353
+
354
+ if (outputPath) {
355
+ const tmp = evaluateExpressionSafe(`${outputPath}=_`, { _: results }) as Record<string, unknown> | undefined;
356
+ if (tmp && typeof tmp === 'object') {
357
+ Object.assign(results, tmp);
358
+ }
359
+ }
360
+
361
+ return traces.length > 0 ? { output: results, traceData: { udf: traces } } : { output: results };
362
+ };
363
+
364
+ return execCtx ? runWithExecContext(execCtx, execute) : execute();
365
+ }
366
+
367
+ private async executeExpr(
368
+ execExpr: ExprAstItem,
369
+ nodeInput: unknown,
370
+ context: Record<string, unknown>,
371
+ traces: UdfTrace[],
372
+ ): Promise<unknown> {
373
+ try {
374
+ const exprId = execExpr.id;
375
+ const exprAst = execExpr.value;
376
+
377
+ const ast = Array.isArray(exprAst) ? exprAst : DecisionRuntime.parseOperatorExpr(exprAst);
378
+ const funcName = ast[0] as string;
379
+ const opArgExpressions = ast.slice(1);
380
+
381
+ const inputField = context['inputField'] as string | null;
382
+ const fSchema = this.registry.udfFunctionSchema(funcName);
383
+
384
+ if (fSchema) {
385
+ const args = opArgExpressions.map((i: string) => {
386
+ const expr = inputField ? `${inputField}.${i}` : i;
387
+ return evaluateExpressionSafe(expr, nodeInput);
388
+ });
389
+
390
+ // 执行规范 §6.1:位置参数必填项前置校验
391
+ const paramIssues = this.registry.validatePositionalArgs(funcName, args);
392
+ if (paramIssues.length > 0) {
393
+ traces.push({ key: execExpr.key, name: funcName, micros: 0, code: 'INVALID_PARAM', issues: paramIssues });
394
+ return { error: { code: 'INVALID_PARAM', issues: paramIssues } };
395
+ }
396
+
397
+ const operatorKwargs = this.registry.funcBindParams(funcName, args);
398
+ const kwargs: Record<string, unknown> = {
399
+ ...operatorKwargs,
400
+ ...context,
401
+ func_id: exprId,
402
+ expr_id: exprId,
403
+ _node_input_: nodeInput,
404
+ };
405
+
406
+ // 执行规范 §6.3:per-tenant 并发闸(注入 limiter 时生效)
407
+ const tenantId = getExecContext()?.tenantId;
408
+ const release = this.limiter && tenantId ? await this.limiter.acquire(tenantId) : null;
409
+ let result: unknown;
410
+ const startedAt = process.hrtime.bigint();
411
+ try {
412
+ // 执行规范 §6.2:kwargs.timeout 约定(毫秒)——运行时级超时兜底,超时返回结构化错误
413
+ const timeoutMs = typeof kwargs.timeout === 'number' && kwargs.timeout > 0 ? kwargs.timeout : null;
414
+ const call = this.registry.call(funcName, kwargs);
415
+ result = timeoutMs
416
+ ? await Promise.race([
417
+ call,
418
+ new Promise((_resolve, reject) =>
419
+ setTimeout(() => reject(new Error('udf timeout after ' + timeoutMs + 'ms')), timeoutMs),
420
+ ),
421
+ ])
422
+ : await call;
423
+ } catch (timeoutError) {
424
+ const message = timeoutError instanceof Error ? timeoutError.message : String(timeoutError);
425
+ if (message.includes('udf timeout')) {
426
+ traces.push({ key: execExpr.key, name: funcName, micros: 0, code: 'UDF_TIMEOUT' });
427
+ return { error: { code: 'UDF_TIMEOUT', message } };
428
+ }
429
+ throw timeoutError;
430
+ } finally {
431
+ release?.();
432
+ }
433
+ const micros = Number(process.hrtime.bigint() - startedAt) / 1000;
434
+
435
+ // 执行规范 §6.5:返回值契约(缺省 warn——只记账不改行为;enforce 换成结构化错误)
436
+ if (this.resultValidation !== 'off') {
437
+ const resultIssues = this.registry.validateResult(funcName, result);
438
+ if (resultIssues.length > 0) {
439
+ traces.push({
440
+ key: execExpr.key,
441
+ name: funcName,
442
+ micros,
443
+ code: 'INVALID_RESULT',
444
+ issues: resultIssues,
445
+ });
446
+ if (this.resultValidation === 'enforce') {
447
+ return { error: { code: 'INVALID_RESULT', issues: resultIssues } };
448
+ }
449
+ }
450
+ }
451
+
452
+ traces.push({ key: execExpr.key, name: funcName, micros });
453
+ return result;
454
+ } else {
455
+ if (funcName) {
456
+ traces.push({ key: execExpr.key, name: funcName, micros: 0, code: 'UDF_NOT_FOUND' });
457
+ return { error: `udf ${funcName} not found` };
458
+ }
459
+ traces.push({ key: execExpr.key, name: '', micros: 0, code: 'UDF_NOT_FOUND' });
460
+ return { error: 'empty udf name not allowed' };
461
+ }
462
+ } catch (error) {
463
+ // UDF 抛错不下发为 null(否则 simulator 无痕吞错):以结构化错误对象出现在 trace/输出中
464
+ traces.push({ key: execExpr.key, name: '', micros: 0, code: 'UDF_ERROR' });
465
+ return { error: this.sanitizer(error instanceof Error ? error.message : String(error)) };
466
+ }
467
+ }
468
+
469
+ udfFunctionSchemaTools(): unknown[] {
470
+ return this.registry.udfFunctionSchemaTools();
471
+ }
472
+ }
473
+
474
+ export { DecisionRuntime };
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { getExecContext, runWithExecContext } from './exec-context.ts';
4
+ import { globalUdfRegistry, registerUdf } from './register.ts';
5
+
6
+ describe('exec-context', () => {
7
+ test('无上下文时返回 undefined', () => {
8
+ expect(getExecContext()).toBeUndefined();
9
+ });
10
+
11
+ test('并发交错链路各自读取自己的上下文', async () => {
12
+ const probe = async (): Promise<string | undefined> => {
13
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 15));
14
+ return getExecContext()?.userId;
15
+ };
16
+ const results = await Promise.all([
17
+ runWithExecContext({ userId: 'u-a', requestId: 'r-1' }, probe),
18
+ runWithExecContext({ userId: 'u-b', requestId: 'r-2' }, probe),
19
+ runWithExecContext({ userId: 'u-c' }, probe),
20
+ ]);
21
+ expect(results).toEqual(['u-a', 'u-b', 'u-c']);
22
+ });
23
+
24
+ test('UDF 经 getExecContext 读到各自 userId(并发注册函数探针)', async () => {
25
+ registerUdf('exec_probe_test', 'risk', {
26
+ description: 'test probe',
27
+ parametersSchema: { properties: {}, title: 'exec_probe_test', type: 'object' },
28
+ returnsSchema: { type: 'object', title: 'probe', properties: {} },
29
+ })(function execProbeUdf() {
30
+ return { caller: getExecContext()?.userId ?? null };
31
+ });
32
+
33
+ const callProbe = () => globalUdfRegistry.call('exec_probe_test', {}) as Promise<{ caller: string | null }>;
34
+ const [a, b] = await Promise.all([
35
+ runWithExecContext({ userId: 'user-a' }, callProbe),
36
+ runWithExecContext({ userId: 'user-b' }, callProbe),
37
+ ]);
38
+ expect(a.caller).toBe('user-a');
39
+ expect(b.caller).toBe('user-b');
40
+ });
41
+
42
+ test('runWithExecContext 透传返回值并向上冒泡异常', async () => {
43
+ await expect(
44
+ runWithExecContext({ userId: 'x' }, async () => {
45
+ throw new Error('boom');
46
+ }),
47
+ ).rejects.toThrow('boom');
48
+
49
+ const value = await runWithExecContext({ userId: 'x' }, async () => 42);
50
+ expect(value).toBe(42);
51
+ });
52
+ });
@@ -0,0 +1,32 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ export interface ExecContext {
4
+ /** 租户标识:多租户运行时强制要求(见 DecisionRuntime.evaluate 入口校验) */
5
+ tenantId?: string;
6
+ userId?: string;
7
+ requestId?: string;
8
+ /**
9
+ * 显式单租户豁免:置 true 后 evaluate 入口不再强制 tenantId,
10
+ * 仅限 CLI / 本地 / 单租户部署使用;多租户服务端禁止开启。
11
+ */
12
+ tenantExempt?: boolean;
13
+ }
14
+
15
+ const execStorage = new AsyncLocalStorage<ExecContext>();
16
+
17
+ export const getExecContext = (): ExecContext | undefined => {
18
+ return execStorage.getStore();
19
+ };
20
+
21
+ export const runWithExecContext = <T>(ctx: ExecContext, fn: () => Promise<T>): Promise<T> => {
22
+ return execStorage.run(ctx, fn);
23
+ };
24
+
25
+ /**
26
+ * ExecContext 的跨原生边界通道键:zen-engine 的 customNode 回调经 Rust worker →
27
+ * napi TSFN 派发回 JS 主线程,AsyncLocalStorage 不跨该边界存活(探针实证)。
28
+ * DecisionRuntime.evaluate 把当前 ExecContext 以此保留键嵌入输入对象,
29
+ * handleCustomNode 提取后用 runWithExecContext 重建立上下文,并对 passThrough
30
+ * 输出剥离该键。仅对象形态输入支持(数组/原始值输入无法承载)。
31
+ */
32
+ export const EXEC_CONTEXT_INPUT_KEY = '__zen_udf_exec_ctx__';