@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
@@ -0,0 +1,154 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { DecisionRuntime } from './engine.ts';
4
+ import { getExecContext, runWithExecContext } from './exec-context.ts';
5
+ import './reference.ts';
6
+ import { UdfRegistry } from './register.ts';
7
+
8
+ // 缺省构造回落 globalUdfRegistry:装载参考域
9
+
10
+ const graph = {
11
+ id: 'tenant-graph',
12
+ nodes: [
13
+ { id: 'in', type: 'inputNode', name: 'Request' },
14
+ { id: 'out', type: 'outputNode', name: 'Response' },
15
+ ],
16
+ edges: [],
17
+ };
18
+
19
+ /** 带 customNode 的图:expr 调用 probe_udf 并把结果写到 out 字段 */
20
+ const customGraph = (id: string) => ({
21
+ id,
22
+ nodes: [
23
+ { id: 'in', type: 'inputNode', name: 'Request' },
24
+ {
25
+ id: 'c1',
26
+ type: 'customNode',
27
+ name: 'custom',
28
+ content: {
29
+ kind: 'UDF',
30
+ config: { expressions: [{ id: 'e1', key: 'out', value: 'probe_udf;;x' }] },
31
+ },
32
+ },
33
+ { id: 'out', type: 'outputNode', name: 'Response' },
34
+ ],
35
+ edges: [
36
+ { id: 'ed1', sourceId: 'in', targetId: 'c1', type: 'edge' },
37
+ { id: 'ed2', sourceId: 'c1', targetId: 'out', type: 'edge' },
38
+ ],
39
+ });
40
+
41
+ describe('DecisionRuntime 租户上下文强制(U2)', () => {
42
+ test('无租户上下文时 evaluate 拒绝执行(fail closed)', async () => {
43
+ const runtime = new DecisionRuntime({});
44
+ await runWithExecContext({ tenantId: 'setup' }, () => {
45
+ runtime.createDecisionWithCacheKey('k', structuredClone(graph));
46
+ return Promise.resolve();
47
+ });
48
+
49
+ await expect(runtime.evaluateAsync('k', { x: 1 })).rejects.toThrow('missing exec context tenantId');
50
+ await expect(runWithExecContext({ userId: 'u1' }, () => runtime.evaluateAsync('k', { x: 1 }))).rejects.toThrow(
51
+ 'missing exec context tenantId',
52
+ );
53
+ });
54
+
55
+ test('携带 tenantId 时正常执行', async () => {
56
+ const runtime = new DecisionRuntime({});
57
+ await runWithExecContext({ tenantId: 't-1' }, () => {
58
+ runtime.createDecisionWithCacheKey('k', structuredClone(graph));
59
+ return Promise.resolve();
60
+ });
61
+
62
+ const result = await runWithExecContext({ tenantId: 't-1', userId: 'u1' }, () =>
63
+ runtime.evaluateAsync('k', { x: 1 }),
64
+ );
65
+ expect(result.result).toBeDefined();
66
+ });
67
+
68
+ test('tenantExempt 豁免单租户部署', async () => {
69
+ const runtime = new DecisionRuntime({});
70
+ await runWithExecContext({ tenantExempt: true }, () => {
71
+ runtime.createDecisionWithCacheKey('k', structuredClone(graph));
72
+ return Promise.resolve();
73
+ });
74
+
75
+ const result = await runWithExecContext({ tenantExempt: true }, () => runtime.evaluateAsync('k', { x: 1 }));
76
+ expect(result.result).toBeDefined();
77
+ });
78
+
79
+ test('tenantId 经 AsyncLocalStorage 贯穿并发链路', async () => {
80
+ const probe = async (): Promise<string | undefined> => {
81
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 10));
82
+ return getExecContext()?.tenantId;
83
+ };
84
+ const results = await Promise.all([
85
+ runWithExecContext({ tenantId: 't-a' }, probe),
86
+ runWithExecContext({ tenantId: 't-b' }, probe),
87
+ runWithExecContext({ tenantId: 't-c' }, probe),
88
+ ]);
89
+ expect(results).toEqual(['t-a', 't-b', 't-c']);
90
+ });
91
+ });
92
+
93
+ describe('DecisionRuntime 实例注入(U3)', () => {
94
+ const makeRegistry = (tag: string): UdfRegistry => {
95
+ const registry = new UdfRegistry();
96
+ registry.registerFunction(function probe_udf(kwargs: Record<string, unknown>) {
97
+ return { tag, x: kwargs?.x ?? null };
98
+ }, 'probe');
99
+ return registry;
100
+ };
101
+
102
+ test('两个运行时实例同名 UDF 各自解析,互不串扰', async () => {
103
+ const ra = new DecisionRuntime({ registry: makeRegistry('A') });
104
+ const rb = new DecisionRuntime({ registry: makeRegistry('B') });
105
+ await runWithExecContext({ tenantId: 't-1' }, () => {
106
+ ra.createDecisionWithCacheKey('k', customGraph('ga'));
107
+ rb.createDecisionWithCacheKey('k', customGraph('gb'));
108
+ return Promise.resolve();
109
+ });
110
+
111
+ const [a, b] = await Promise.all([
112
+ runWithExecContext({ tenantId: 't-1' }, () => ra.evaluateAsync('k', { x: 1 })),
113
+ runWithExecContext({ tenantId: 't-1' }, () => rb.evaluateAsync('k', { x: 2 })),
114
+ ]);
115
+ expect((a.result as { out: { tag: string } }).out.tag).toBe('A');
116
+ expect((b.result as { out: { tag: string } }).out.tag).toBe('B');
117
+ });
118
+
119
+ test('空注册表的运行时对未知 UDF 返回结构化错误而非抛出', async () => {
120
+ const runtime = new DecisionRuntime({ registry: new UdfRegistry() });
121
+ await runWithExecContext({ tenantId: 't-1' }, () => {
122
+ runtime.createDecisionWithCacheKey('k', customGraph('g-empty'));
123
+ return Promise.resolve();
124
+ });
125
+
126
+ const result = await runWithExecContext({ tenantId: 't-1' }, () => runtime.evaluateAsync('k', { x: 1 }));
127
+ const serialized = JSON.stringify(result);
128
+ expect(serialized).toContain('udf probe_udf not found');
129
+ });
130
+
131
+ test('缺省构造回落 globalUdfRegistry(reference 装载后 http 可用)', () => {
132
+ const runtime = new DecisionRuntime();
133
+ expect(runtime.udfFunctionSchemaTools().length).toBeGreaterThan(0);
134
+ });
135
+
136
+ test('ExecContext 经输入保留键贯穿 TSFN 边界,UDF 内可读(ALS 重建立)', async () => {
137
+ const registry = new UdfRegistry();
138
+ registry.registerFunction(function probe_udf() {
139
+ return { tenantInUdf: getExecContext()?.tenantId ?? null, userInUdf: getExecContext()?.userId ?? null };
140
+ }, 'probe');
141
+ const runtime = new DecisionRuntime({ registry });
142
+ await runWithExecContext({ tenantId: 't-1' }, () => {
143
+ runtime.createDecisionWithCacheKey('k', customGraph('g-als'));
144
+ return Promise.resolve();
145
+ });
146
+
147
+ const result = await runWithExecContext({ tenantId: 't-1', userId: 'u-9' }, () =>
148
+ runtime.evaluateAsync('k', { x: 1 }),
149
+ );
150
+ const out = (result.result as { out?: { tenantInUdf?: string; userInUdf?: string } }).out;
151
+ expect(out?.tenantInUdf).toBe('t-1');
152
+ expect(out?.userInUdf).toBe('u-9');
153
+ });
154
+ });
@@ -0,0 +1,87 @@
1
+ import { ZenEngine } from '@gorules/zen-engine';
2
+ import { describe, expect, test } from 'vitest';
3
+
4
+ /**
5
+ * zen-engine 2.0.2 缓存语义哨兵(源码核对 + 探针实证,设计见 docs/design/zen-udf-multi-tenant.md):
6
+ * - 函数 loader 无引擎级缓存:engine.evaluate(key)/getDecision(key) 每次回调 loader 并重新解析
7
+ * - createDecision 返回的 ZenDecision 可复用:解析一次、evaluate N 次
8
+ * - customNode 不影响上述语义;customHandler 每次 evaluate 回调
9
+ * 多租户缓存架构(宿主自管 Map<key, ZenDecision>)建立在这些语义之上;
10
+ * 升级 zen-engine 后本测试若失败,说明缓存行为变化,需重新评估缓存设计。
11
+ */
12
+ const graph = {
13
+ id: 'g',
14
+ nodes: [
15
+ { id: 'in', type: 'inputNode', name: 'Request' },
16
+ { id: 'out', type: 'outputNode', name: 'Response' },
17
+ ],
18
+ edges: [],
19
+ };
20
+
21
+ describe('zen-engine 缓存语义(2.0.2 实证)', () => {
22
+ test('函数 loader:evaluate(key) 每次回调 loader(引擎不缓存)', async () => {
23
+ let loads = 0;
24
+ const engine = new ZenEngine({
25
+ loader: async () => {
26
+ loads += 1;
27
+ return Buffer.from(JSON.stringify(graph));
28
+ },
29
+ });
30
+ await engine.evaluate('k', { a: 1 });
31
+ await engine.evaluate('k', { a: 2 });
32
+ expect(loads).toBe(2);
33
+ });
34
+
35
+ test('函数 loader:getDecision(key) 每次回调 loader', async () => {
36
+ let loads = 0;
37
+ const engine = new ZenEngine({
38
+ loader: async () => {
39
+ loads += 1;
40
+ return Buffer.from(JSON.stringify(graph));
41
+ },
42
+ });
43
+ await engine.getDecision('k');
44
+ await engine.getDecision('k');
45
+ expect(loads).toBe(2);
46
+ });
47
+
48
+ test('createDecision 实例可复用:解析一次 evaluate 多次', async () => {
49
+ const engine = new ZenEngine({});
50
+ const decision = engine.createDecision(structuredClone(graph));
51
+ const first = await decision.evaluate({ x: 1 });
52
+ const second = await decision.evaluate({ x: 2 });
53
+ expect(first.result).toBeDefined();
54
+ expect(second.result).toBeDefined();
55
+ });
56
+
57
+ test('customNode:缓存决策复用不受影响,handler 每次 evaluate 回调', async () => {
58
+ let handlerCalls = 0;
59
+ const engine = new ZenEngine({
60
+ customHandler: async () => {
61
+ handlerCalls += 1;
62
+ return { output: { handled: true } };
63
+ },
64
+ });
65
+ const customGraph = {
66
+ id: 'gc',
67
+ nodes: [
68
+ { id: 'in', type: 'inputNode', name: 'Request' },
69
+ {
70
+ id: 'c1',
71
+ type: 'customNode',
72
+ name: 'custom',
73
+ content: { kind: 'UDF', config: { expressions: [{ id: 'e1', key: 'r', value: 'some_udf;;x' }] } },
74
+ },
75
+ { id: 'out', type: 'outputNode', name: 'Response' },
76
+ ],
77
+ edges: [
78
+ { id: 'e1', sourceId: 'in', targetId: 'c1', type: 'edge' },
79
+ { id: 'e2', sourceId: 'c1', targetId: 'out', type: 'edge' },
80
+ ],
81
+ };
82
+ const decision = engine.createDecision(customGraph);
83
+ await decision.evaluate({ x: 1 });
84
+ await decision.evaluate({ x: 2 });
85
+ expect(handlerCalls).toBe(2);
86
+ });
87
+ });