payid-rule-engine 0.2.1 → 0.2.3
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 +3 -2
- package/src/index.ts +3 -3
- package/src/sandbox.ts +36 -243
- package/src/tsSandbox.ts +262 -0
- package/src/wasm/rule_engine.wasm +0 -0
- package/src/wasm.ts +21 -26
- package/tsup.config.ts +11 -0
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payid-rule-engine",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"
|
|
7
|
+
"buildOld": "tsup src/* --format esm --dts --clean",
|
|
8
|
+
"build": "tsup"
|
|
8
9
|
},
|
|
9
10
|
"dependencies": {
|
|
10
11
|
"@types/lodash": "^4.17.21",
|
package/src/index.ts
CHANGED
|
@@ -13,9 +13,9 @@ export * from "./preprocess";
|
|
|
13
13
|
* @returns RuleResult (ALLOW / REJECT)
|
|
14
14
|
*/
|
|
15
15
|
export async function executeRule(
|
|
16
|
-
wasmBinary: Buffer,
|
|
17
16
|
context: RuleContext,
|
|
18
|
-
ruleConfig: unknown
|
|
17
|
+
ruleConfig: unknown,
|
|
18
|
+
wasmBinary: Buffer,
|
|
19
19
|
): Promise<RuleResult> {
|
|
20
|
-
return runWasmRule(
|
|
20
|
+
return runWasmRule(context, ruleConfig, wasmBinary,);
|
|
21
21
|
}
|
package/src/sandbox.ts
CHANGED
|
@@ -1,262 +1,55 @@
|
|
|
1
|
-
// sandbox.ts — Pure TypeScript rule engine (no WASM)
|
|
2
|
-
//
|
|
3
|
-
// Implements semua operator v4: exists, not_exists, transforms, regex, mod_ne, dll.
|
|
4
|
-
// Tidak butuh compile Rust, tidak butuh WASI.
|
|
5
|
-
|
|
6
1
|
import type { RuleContext, RuleResult } from "payid-types";
|
|
7
|
-
|
|
8
|
-
// ── Entry point (sama interface dengan runWasmRule) ───────────────────────────
|
|
9
|
-
|
|
2
|
+
import { loadWasm } from "./wasm";
|
|
10
3
|
export async function runWasmRule(
|
|
11
|
-
_wasmBinary: Buffer, // ignored — pakai TS implementation
|
|
12
4
|
context: RuleContext,
|
|
13
|
-
config: any
|
|
5
|
+
config: any,
|
|
6
|
+
wasmBinary?: Buffer,
|
|
14
7
|
): Promise<RuleResult> {
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// ── Core evaluation ───────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
function evaluateRule(context: any, config: any): RuleResult {
|
|
21
|
-
const rules: any[] = config?.rules;
|
|
22
|
-
if (!Array.isArray(rules) || rules.length === 0) {
|
|
23
|
-
return { decision: "ALLOW", code: "NO_RULES", reason: "no rules defined" };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const logic: string = config?.logic ?? "AND";
|
|
27
|
-
return evalRules(context, rules, logic);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function evalRules(context: any, rules: any[], logic: string): RuleResult {
|
|
31
|
-
for (const rule of rules) {
|
|
32
|
-
const res = evalOneRule(context, rule);
|
|
33
|
-
if (res.decision === "REJECT" && logic === "AND") return res;
|
|
34
|
-
if (res.decision === "ALLOW" && logic === "OR") return res;
|
|
35
|
-
}
|
|
36
|
-
if (logic === "AND") return { decision: "ALLOW", code: "OK", reason: "all rules passed" };
|
|
37
|
-
return { decision: "REJECT", code: "NO_RULE_MATCH", reason: "no rule matched in OR group" };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function evalOneRule(context: any, rule: any): RuleResult {
|
|
41
|
-
const ruleId = rule?.id ?? "UNKNOWN_RULE";
|
|
42
|
-
const message = rule?.message ?? "";
|
|
43
|
-
|
|
44
|
-
// Format C: nested rules
|
|
45
|
-
if (Array.isArray(rule?.rules)) {
|
|
46
|
-
const subLogic = rule?.logic ?? "AND";
|
|
47
|
-
const res = evalRules(context, rule.rules, subLogic);
|
|
48
|
-
if (res.decision === "REJECT" && message) {
|
|
49
|
-
return { decision: "REJECT", code: ruleId, reason: message };
|
|
50
|
-
}
|
|
51
|
-
return res;
|
|
52
|
-
}
|
|
8
|
+
const instance = await loadWasm(wasmBinary);
|
|
53
9
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const reason = message || cond?.field || ruleId;
|
|
61
|
-
return { decision: "REJECT", code: ruleId, reason };
|
|
62
|
-
}
|
|
63
|
-
if (passed && inner === "OR") {
|
|
64
|
-
return { decision: "ALLOW", code: ruleId };
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
if (inner === "AND") return { decision: "ALLOW", code: ruleId };
|
|
68
|
-
return { decision: "REJECT", code: ruleId, reason: message || "no condition matched in OR" };
|
|
69
|
-
}
|
|
10
|
+
const memory = instance.exports.memory as WebAssembly.Memory;
|
|
11
|
+
const alloc = instance.exports.alloc as ((size: number) => number) | undefined;
|
|
12
|
+
const free_ = instance.exports.free as ((ptr: number, size: number) => void) | undefined;
|
|
13
|
+
const evaluate = instance.exports.evaluate as (
|
|
14
|
+
a: number, b: number, c: number, d: number, e: number, f: number
|
|
15
|
+
) => number;
|
|
70
16
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const passed = evalCondition(context, rule.if);
|
|
74
|
-
if (!passed) {
|
|
75
|
-
const reason = message || rule.if?.field || ruleId;
|
|
76
|
-
return {
|
|
77
|
-
decision: "REJECT",
|
|
78
|
-
code: ruleId,
|
|
79
|
-
reason: interpolate(reason, context)
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
return { decision: "ALLOW", code: ruleId };
|
|
17
|
+
if (!alloc || !evaluate) {
|
|
18
|
+
throw new Error(`WASM missing exports: alloc=${!!alloc} evaluate=${!!evaluate}`);
|
|
83
19
|
}
|
|
84
20
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// ── Condition evaluation ──────────────────────────────────────────────────────
|
|
89
|
-
|
|
90
|
-
function evalCondition(context: any, cond: any): boolean {
|
|
91
|
-
const fieldExpr: string = cond?.field;
|
|
92
|
-
const op: string = cond?.op;
|
|
93
|
-
if (!fieldExpr || !op) return false;
|
|
94
|
-
|
|
95
|
-
const baseField = splitTransform(fieldExpr)[0];
|
|
96
|
-
|
|
97
|
-
if (op === "exists") return resolveField(context, baseField) !== undefined;
|
|
98
|
-
if (op === "not_exists") return resolveField(context, baseField) === undefined;
|
|
99
|
-
|
|
100
|
-
const actualRaw = resolveField(context, baseField);
|
|
101
|
-
if (actualRaw === undefined) return false;
|
|
102
|
-
const actual = applyTransform(actualRaw, fieldExpr);
|
|
103
|
-
|
|
104
|
-
// Cross-field reference
|
|
105
|
-
let expected = cond.value;
|
|
106
|
-
if (typeof expected === "string" && expected.startsWith("$")) {
|
|
107
|
-
const refField = expected.slice(1);
|
|
108
|
-
const refBase = splitTransform(refField)[0];
|
|
109
|
-
const refRaw = resolveField(context, refBase);
|
|
110
|
-
if (refRaw === undefined) return false;
|
|
111
|
-
expected = applyTransform(refRaw, refField);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return applyOp(actual, op, expected);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ── Field resolution ──────────────────────────────────────────────────────────
|
|
21
|
+
const ctxBuf = Buffer.from(JSON.stringify(context));
|
|
22
|
+
const cfgBuf = Buffer.from(JSON.stringify(config));
|
|
23
|
+
const OUT_SIZE = 4096;
|
|
118
24
|
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
}
|
|
25
|
+
const ctxPtr = alloc(ctxBuf.length);
|
|
26
|
+
const cfgPtr = alloc(cfgBuf.length);
|
|
27
|
+
const outPtr = alloc(OUT_SIZE);
|
|
123
28
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (i === -1) return [expr, null];
|
|
127
|
-
return [expr.slice(0, i), expr.slice(i + 1)];
|
|
128
|
-
}
|
|
29
|
+
new Uint8Array(memory.buffer).set(ctxBuf, ctxPtr);
|
|
30
|
+
new Uint8Array(memory.buffer).set(cfgBuf, cfgPtr);
|
|
129
31
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
function applyTransform(val: any, expr: string): any {
|
|
133
|
-
const transform = splitTransform(expr)[1];
|
|
134
|
-
if (!transform) return val;
|
|
135
|
-
|
|
136
|
-
const colonIdx = transform.indexOf(":");
|
|
137
|
-
const name = colonIdx === -1 ? transform : transform.slice(0, colonIdx);
|
|
138
|
-
const arg = colonIdx === -1 ? null : transform.slice(colonIdx + 1);
|
|
139
|
-
|
|
140
|
-
const n = toU128(val);
|
|
141
|
-
|
|
142
|
-
switch (name) {
|
|
143
|
-
case "div": {
|
|
144
|
-
if (n === null) return val;
|
|
145
|
-
const d = arg ? BigInt(arg) : 1n;
|
|
146
|
-
if (d === 0n) return val;
|
|
147
|
-
return Number(n / d);
|
|
148
|
-
}
|
|
149
|
-
case "mod": {
|
|
150
|
-
if (n === null) return val;
|
|
151
|
-
const m = arg ? BigInt(arg) : 1n;
|
|
152
|
-
if (m === 0n) return val;
|
|
153
|
-
return Number(n % m);
|
|
154
|
-
}
|
|
155
|
-
case "abs": return n !== null ? Number(n < 0n ? -n : n) : val;
|
|
156
|
-
case "hour": return n !== null ? Number((n % 86400n) / 3600n) : val;
|
|
157
|
-
case "day": return n !== null ? Number((n / 86400n + 4n) % 7n) : val;
|
|
158
|
-
case "date": return n !== null ? dayOfMonth(Number(n / 86400n)) : val;
|
|
159
|
-
case "month": return n !== null ? monthOfYear(Number(n / 86400n)) : val;
|
|
160
|
-
case "len": return String(val).length;
|
|
161
|
-
case "lower": return String(val).toLowerCase();
|
|
162
|
-
case "upper": return String(val).toUpperCase();
|
|
163
|
-
default: return val;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function toU128(v: any): bigint | null {
|
|
32
|
+
let rc: number;
|
|
168
33
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
} catch { return null; }
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── Gregorian calendar ────────────────────────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
function isLeap(y: number): boolean { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }
|
|
179
|
-
|
|
180
|
-
function daysToYMD(days: number): [number, number, number] {
|
|
181
|
-
let y = 1970;
|
|
182
|
-
while (true) { const dy = isLeap(y) ? 366 : 365; if (days < dy) break; days -= dy; y++; }
|
|
183
|
-
const months = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
184
|
-
if (isLeap(y)) months[2] = 29;
|
|
185
|
-
let m = 1;
|
|
186
|
-
while (true) { if (days < months[m]!) break; days -= months[m]!; m++; }
|
|
187
|
-
return [y, m, days + 1];
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function dayOfMonth(days: number): number { return daysToYMD(days)[2]; }
|
|
191
|
-
function monthOfYear(days: number): number { return daysToYMD(days)[1]; }
|
|
192
|
-
|
|
193
|
-
// ── Operator dispatch ─────────────────────────────────────────────────────────
|
|
194
|
-
|
|
195
|
-
function applyOp(actual: any, op: string, expected: any): boolean {
|
|
196
|
-
const a = toU128(actual);
|
|
197
|
-
const b = toU128(expected);
|
|
198
|
-
|
|
199
|
-
switch (op) {
|
|
200
|
-
case ">=": return a !== null && b !== null && a >= b;
|
|
201
|
-
case "<=": return a !== null && b !== null && a <= b;
|
|
202
|
-
case ">": return a !== null && b !== null && a > b;
|
|
203
|
-
case "<": return a !== null && b !== null && a < b;
|
|
204
|
-
|
|
205
|
-
case "==": return String(actual) === String(expected) || actual == expected;
|
|
206
|
-
case "!=": return String(actual) !== String(expected) && actual != expected;
|
|
207
|
-
|
|
208
|
-
case "in": return Array.isArray(expected) && expected.some(e => looseEq(actual, e));
|
|
209
|
-
case "not_in": return Array.isArray(expected) && !expected.some(e => looseEq(actual, e));
|
|
210
|
-
|
|
211
|
-
case "between":
|
|
212
|
-
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
213
|
-
&& toU128(expected[0]) !== null && toU128(expected[1]) !== null
|
|
214
|
-
&& a >= toU128(expected[0])! && a <= toU128(expected[1])!;
|
|
215
|
-
|
|
216
|
-
case "not_between":
|
|
217
|
-
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
218
|
-
&& (a < toU128(expected[0])! || a > toU128(expected[1])!);
|
|
219
|
-
|
|
220
|
-
case "mod_eq":
|
|
221
|
-
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
222
|
-
&& toU128(expected[0]) !== null && toU128(expected[0])! > 0n
|
|
223
|
-
&& a % toU128(expected[0])! === toU128(expected[1])!;
|
|
224
|
-
|
|
225
|
-
case "mod_ne":
|
|
226
|
-
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
227
|
-
&& toU128(expected[0]) !== null && toU128(expected[0])! > 0n
|
|
228
|
-
&& a % toU128(expected[0])! !== toU128(expected[1])!;
|
|
34
|
+
rc = evaluate(ctxPtr, ctxBuf.length, cfgPtr, cfgBuf.length, outPtr, OUT_SIZE);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(`WASM evaluate threw: ${err}`);
|
|
37
|
+
}
|
|
229
38
|
|
|
230
|
-
|
|
231
|
-
case "not_contains": return typeof actual === "string" && typeof expected === "string" && !actual.includes(expected);
|
|
232
|
-
case "starts_with": return typeof actual === "string" && typeof expected === "string" && actual.startsWith(expected);
|
|
233
|
-
case "ends_with": return typeof actual === "string" && typeof expected === "string" && actual.endsWith(expected);
|
|
39
|
+
if (rc < 0) throw new Error(`WASM evaluate failed rc=${rc}`);
|
|
234
40
|
|
|
235
|
-
|
|
236
|
-
|
|
41
|
+
const out = Buffer.from(
|
|
42
|
+
new Uint8Array(memory.buffer).slice(outPtr, outPtr + rc)
|
|
43
|
+
);
|
|
237
44
|
|
|
238
|
-
|
|
239
|
-
case "not_regex": return typeof actual === "string" && typeof expected === "string" && !new RegExp(expected).test(actual);
|
|
45
|
+
const result = JSON.parse(out.toString("utf8"));
|
|
240
46
|
|
|
241
|
-
|
|
47
|
+
// free hanya kalau ada — beberapa build tidak export free
|
|
48
|
+
if (free_) {
|
|
49
|
+
free_(ctxPtr, ctxBuf.length);
|
|
50
|
+
free_(cfgPtr, cfgBuf.length);
|
|
51
|
+
free_(outPtr, OUT_SIZE);
|
|
242
52
|
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function looseEq(a: any, b: any): boolean {
|
|
246
|
-
if (a == b) return true;
|
|
247
|
-
if (String(a) === String(b)) return true;
|
|
248
|
-
const ba = toU128(a), bb = toU128(b);
|
|
249
|
-
return ba !== null && bb !== null && ba === bb;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// ── Message interpolation ─────────────────────────────────────────────────────
|
|
253
53
|
|
|
254
|
-
|
|
255
|
-
return template.replace(/\{([^}]+)\}/g, (_, key) => {
|
|
256
|
-
const base = splitTransform(key)[0];
|
|
257
|
-
const raw = resolveField(context, base);
|
|
258
|
-
if (raw === undefined) return `{${key}}`;
|
|
259
|
-
const val = applyTransform(raw, key);
|
|
260
|
-
return String(val);
|
|
261
|
-
});
|
|
54
|
+
return result;
|
|
262
55
|
}
|
package/src/tsSandbox.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// sandbox.ts — Pure TypeScript rule engine (no WASM)
|
|
2
|
+
//
|
|
3
|
+
// Implements semua operator v4: exists, not_exists, transforms, regex, mod_ne, dll.
|
|
4
|
+
// Tidak butuh compile Rust, tidak butuh WASI.
|
|
5
|
+
|
|
6
|
+
import type { RuleContext, RuleResult } from "payid-types";
|
|
7
|
+
|
|
8
|
+
// ── Entry point (sama interface dengan runWasmRule) ───────────────────────────
|
|
9
|
+
|
|
10
|
+
export async function runWasmRule(
|
|
11
|
+
_wasmBinary: Buffer, // ignored — pakai TS implementation
|
|
12
|
+
context: RuleContext,
|
|
13
|
+
config: any
|
|
14
|
+
): Promise<RuleResult> {
|
|
15
|
+
return evaluateRule(context, config);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Core evaluation ───────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function evaluateRule(context: any, config: any): RuleResult {
|
|
21
|
+
const rules: any[] = config?.rules;
|
|
22
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
23
|
+
return { decision: "ALLOW", code: "NO_RULES", reason: "no rules defined" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const logic: string = config?.logic ?? "AND";
|
|
27
|
+
return evalRules(context, rules, logic);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function evalRules(context: any, rules: any[], logic: string): RuleResult {
|
|
31
|
+
for (const rule of rules) {
|
|
32
|
+
const res = evalOneRule(context, rule);
|
|
33
|
+
if (res.decision === "REJECT" && logic === "AND") return res;
|
|
34
|
+
if (res.decision === "ALLOW" && logic === "OR") return res;
|
|
35
|
+
}
|
|
36
|
+
if (logic === "AND") return { decision: "ALLOW", code: "OK", reason: "all rules passed" };
|
|
37
|
+
return { decision: "REJECT", code: "NO_RULE_MATCH", reason: "no rule matched in OR group" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function evalOneRule(context: any, rule: any): RuleResult {
|
|
41
|
+
const ruleId = rule?.id ?? "UNKNOWN_RULE";
|
|
42
|
+
const message = rule?.message ?? "";
|
|
43
|
+
|
|
44
|
+
// Format C: nested rules
|
|
45
|
+
if (Array.isArray(rule?.rules)) {
|
|
46
|
+
const subLogic = rule?.logic ?? "AND";
|
|
47
|
+
const res = evalRules(context, rule.rules, subLogic);
|
|
48
|
+
if (res.decision === "REJECT" && message) {
|
|
49
|
+
return { decision: "REJECT", code: ruleId, reason: message };
|
|
50
|
+
}
|
|
51
|
+
return res;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Format B: multi-condition
|
|
55
|
+
if (Array.isArray(rule?.conditions)) {
|
|
56
|
+
const inner = rule?.logic ?? "AND";
|
|
57
|
+
for (const cond of rule.conditions) {
|
|
58
|
+
const passed = evalCondition(context, cond);
|
|
59
|
+
if (!passed && inner === "AND") {
|
|
60
|
+
const reason = message || cond?.field || ruleId;
|
|
61
|
+
return { decision: "REJECT", code: ruleId, reason };
|
|
62
|
+
}
|
|
63
|
+
if (passed && inner === "OR") {
|
|
64
|
+
return { decision: "ALLOW", code: ruleId };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (inner === "AND") return { decision: "ALLOW", code: ruleId };
|
|
68
|
+
return { decision: "REJECT", code: ruleId, reason: message || "no condition matched in OR" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Format A: single if
|
|
72
|
+
if (rule?.if !== undefined) {
|
|
73
|
+
const passed = evalCondition(context, rule.if);
|
|
74
|
+
if (!passed) {
|
|
75
|
+
const reason = message || rule.if?.field || ruleId;
|
|
76
|
+
return {
|
|
77
|
+
decision: "REJECT",
|
|
78
|
+
code: ruleId,
|
|
79
|
+
reason: interpolate(reason, context)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { decision: "ALLOW", code: ruleId };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { decision: "REJECT", code: ruleId, reason: "rule has no evaluable condition" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Condition evaluation ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function evalCondition(context: any, cond: any): boolean {
|
|
91
|
+
const fieldExpr: string = cond?.field;
|
|
92
|
+
const op: string = cond?.op;
|
|
93
|
+
if (!fieldExpr || !op) return false;
|
|
94
|
+
|
|
95
|
+
const baseField = splitTransform(fieldExpr)[0];
|
|
96
|
+
|
|
97
|
+
if (op === "exists") return resolveField(context, baseField) !== undefined;
|
|
98
|
+
if (op === "not_exists") return resolveField(context, baseField) === undefined;
|
|
99
|
+
|
|
100
|
+
const actualRaw = resolveField(context, baseField);
|
|
101
|
+
if (actualRaw === undefined) return false;
|
|
102
|
+
const actual = applyTransform(actualRaw, fieldExpr);
|
|
103
|
+
|
|
104
|
+
// Cross-field reference
|
|
105
|
+
let expected = cond.value;
|
|
106
|
+
if (typeof expected === "string" && expected.startsWith("$")) {
|
|
107
|
+
const refField = expected.slice(1);
|
|
108
|
+
const refBase = splitTransform(refField)[0];
|
|
109
|
+
const refRaw = resolveField(context, refBase);
|
|
110
|
+
if (refRaw === undefined) return false;
|
|
111
|
+
expected = applyTransform(refRaw, refField);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return applyOp(actual, op, expected);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Field resolution ──────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function resolveField(ctx: any, path: string): any {
|
|
120
|
+
const base = splitTransform(path)[0];
|
|
121
|
+
return base.split(".").reduce((o: any, k: string) => o?.[k], ctx);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function splitTransform(expr: string): [string, string | null] {
|
|
125
|
+
const i = expr.indexOf("|");
|
|
126
|
+
if (i === -1) return [expr, null];
|
|
127
|
+
return [expr.slice(0, i), expr.slice(i + 1)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Field transforms ──────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function applyTransform(val: any, expr: string): any {
|
|
133
|
+
const transform = splitTransform(expr)[1];
|
|
134
|
+
if (!transform) return val;
|
|
135
|
+
|
|
136
|
+
const colonIdx = transform.indexOf(":");
|
|
137
|
+
const name = colonIdx === -1 ? transform : transform.slice(0, colonIdx);
|
|
138
|
+
const arg = colonIdx === -1 ? null : transform.slice(colonIdx + 1);
|
|
139
|
+
|
|
140
|
+
const n = toU128(val);
|
|
141
|
+
|
|
142
|
+
switch (name) {
|
|
143
|
+
case "div": {
|
|
144
|
+
if (n === null) return val;
|
|
145
|
+
const d = arg ? BigInt(arg) : 1n;
|
|
146
|
+
if (d === 0n) return val;
|
|
147
|
+
return Number(n / d);
|
|
148
|
+
}
|
|
149
|
+
case "mod": {
|
|
150
|
+
if (n === null) return val;
|
|
151
|
+
const m = arg ? BigInt(arg) : 1n;
|
|
152
|
+
if (m === 0n) return val;
|
|
153
|
+
return Number(n % m);
|
|
154
|
+
}
|
|
155
|
+
case "abs": return n !== null ? Number(n < 0n ? -n : n) : val;
|
|
156
|
+
case "hour": return n !== null ? Number((n % 86400n) / 3600n) : val;
|
|
157
|
+
case "day": return n !== null ? Number((n / 86400n + 4n) % 7n) : val;
|
|
158
|
+
case "date": return n !== null ? dayOfMonth(Number(n / 86400n)) : val;
|
|
159
|
+
case "month": return n !== null ? monthOfYear(Number(n / 86400n)) : val;
|
|
160
|
+
case "len": return String(val).length;
|
|
161
|
+
case "lower": return String(val).toLowerCase();
|
|
162
|
+
case "upper": return String(val).toUpperCase();
|
|
163
|
+
default: return val;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toU128(v: any): bigint | null {
|
|
168
|
+
try {
|
|
169
|
+
if (typeof v === "bigint") return v;
|
|
170
|
+
if (typeof v === "number") return BigInt(Math.trunc(v));
|
|
171
|
+
if (typeof v === "string" && v !== "") return BigInt(v);
|
|
172
|
+
return null;
|
|
173
|
+
} catch { return null; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Gregorian calendar ────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function isLeap(y: number): boolean { return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0; }
|
|
179
|
+
|
|
180
|
+
function daysToYMD(days: number): [number, number, number] {
|
|
181
|
+
let y = 1970;
|
|
182
|
+
while (true) { const dy = isLeap(y) ? 366 : 365; if (days < dy) break; days -= dy; y++; }
|
|
183
|
+
const months = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
184
|
+
if (isLeap(y)) months[2] = 29;
|
|
185
|
+
let m = 1;
|
|
186
|
+
while (true) { if (days < months[m]!) break; days -= months[m]!; m++; }
|
|
187
|
+
return [y, m, days + 1];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function dayOfMonth(days: number): number { return daysToYMD(days)[2]; }
|
|
191
|
+
function monthOfYear(days: number): number { return daysToYMD(days)[1]; }
|
|
192
|
+
|
|
193
|
+
// ── Operator dispatch ─────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
function applyOp(actual: any, op: string, expected: any): boolean {
|
|
196
|
+
const a = toU128(actual);
|
|
197
|
+
const b = toU128(expected);
|
|
198
|
+
|
|
199
|
+
switch (op) {
|
|
200
|
+
case ">=": return a !== null && b !== null && a >= b;
|
|
201
|
+
case "<=": return a !== null && b !== null && a <= b;
|
|
202
|
+
case ">": return a !== null && b !== null && a > b;
|
|
203
|
+
case "<": return a !== null && b !== null && a < b;
|
|
204
|
+
|
|
205
|
+
case "==": return String(actual) === String(expected) || actual == expected;
|
|
206
|
+
case "!=": return String(actual) !== String(expected) && actual != expected;
|
|
207
|
+
|
|
208
|
+
case "in": return Array.isArray(expected) && expected.some(e => looseEq(actual, e));
|
|
209
|
+
case "not_in": return Array.isArray(expected) && !expected.some(e => looseEq(actual, e));
|
|
210
|
+
|
|
211
|
+
case "between":
|
|
212
|
+
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
213
|
+
&& toU128(expected[0]) !== null && toU128(expected[1]) !== null
|
|
214
|
+
&& a >= toU128(expected[0])! && a <= toU128(expected[1])!;
|
|
215
|
+
|
|
216
|
+
case "not_between":
|
|
217
|
+
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
218
|
+
&& (a < toU128(expected[0])! || a > toU128(expected[1])!);
|
|
219
|
+
|
|
220
|
+
case "mod_eq":
|
|
221
|
+
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
222
|
+
&& toU128(expected[0]) !== null && toU128(expected[0])! > 0n
|
|
223
|
+
&& a % toU128(expected[0])! === toU128(expected[1])!;
|
|
224
|
+
|
|
225
|
+
case "mod_ne":
|
|
226
|
+
return Array.isArray(expected) && expected.length === 2 && a !== null
|
|
227
|
+
&& toU128(expected[0]) !== null && toU128(expected[0])! > 0n
|
|
228
|
+
&& a % toU128(expected[0])! !== toU128(expected[1])!;
|
|
229
|
+
|
|
230
|
+
case "contains": return typeof actual === "string" && typeof expected === "string" && actual.includes(expected);
|
|
231
|
+
case "not_contains": return typeof actual === "string" && typeof expected === "string" && !actual.includes(expected);
|
|
232
|
+
case "starts_with": return typeof actual === "string" && typeof expected === "string" && actual.startsWith(expected);
|
|
233
|
+
case "ends_with": return typeof actual === "string" && typeof expected === "string" && actual.endsWith(expected);
|
|
234
|
+
|
|
235
|
+
case "exists": return actual !== undefined && actual !== null;
|
|
236
|
+
case "not_exists": return actual === undefined || actual === null;
|
|
237
|
+
|
|
238
|
+
case "regex": return typeof actual === "string" && typeof expected === "string" && new RegExp(expected).test(actual);
|
|
239
|
+
case "not_regex": return typeof actual === "string" && typeof expected === "string" && !new RegExp(expected).test(actual);
|
|
240
|
+
|
|
241
|
+
default: return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function looseEq(a: any, b: any): boolean {
|
|
246
|
+
if (a == b) return true;
|
|
247
|
+
if (String(a) === String(b)) return true;
|
|
248
|
+
const ba = toU128(a), bb = toU128(b);
|
|
249
|
+
return ba !== null && bb !== null && ba === bb;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Message interpolation ─────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
function interpolate(template: string, context: any): string {
|
|
255
|
+
return template.replace(/\{([^}]+)\}/g, (_, key) => {
|
|
256
|
+
const base = splitTransform(key)[0];
|
|
257
|
+
const raw = resolveField(context, base);
|
|
258
|
+
if (raw === undefined) return `{${key}}`;
|
|
259
|
+
const val = applyTransform(raw, key);
|
|
260
|
+
return String(val);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
Binary file
|
package/src/wasm.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// WASM rule engine v4 tidak butuh WASI sama sekali (tidak ada file I/O,
|
|
4
|
-
// tidak ada stdout/stderr, tidak ada proc_exit). Bun punya known issues
|
|
5
|
-
// dengan WASI preview1 yang bisa menyebabkan hang.
|
|
6
|
-
//
|
|
7
|
-
// Solusi: pass minimal stub yang satisfy wasi_snapshot_preview1 interface,
|
|
8
|
-
// cukup untuk Rust allocator init tanpa dependency ke WASI runtime.
|
|
1
|
+
export async function loadWasm(binary?: Buffer): Promise<WebAssembly.Instance> {
|
|
2
|
+
let wasmBinary = binary;
|
|
9
3
|
|
|
10
|
-
|
|
4
|
+
if (!wasmBinary || wasmBinary.length === 0) {
|
|
5
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
6
|
+
const { readFileSync } = await import("fs");
|
|
7
|
+
const { join, dirname } = await import("path");
|
|
8
|
+
const { fileURLToPath } = await import("url");
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
): Promise<WebAssembly.Instance> {
|
|
10
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
wasmBinary = readFileSync(join(__dir, "wasm/rule_engine.wasm"));
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
// Browser
|
|
14
|
+
} else {
|
|
15
|
+
const wasmUrl = new URL("../wasm/rule_engine.wasm", import.meta.url);
|
|
16
|
+
const res = await fetch(wasmUrl);
|
|
17
|
+
if (!res.ok) throw new Error(`Failed to load rule_engine.wasm: ${res.status}`);
|
|
18
|
+
wasmBinary = Buffer.from(await res.arrayBuffer());
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const module = await WebAssembly.compile(wasmBinary);
|
|
19
23
|
|
|
20
24
|
const wasiStub: Record<string, (...args: any[]) => any> = {
|
|
21
25
|
fd_write: () => 8,
|
|
@@ -34,21 +38,12 @@ export async function loadWasm(
|
|
|
34
38
|
random_get: () => 0,
|
|
35
39
|
};
|
|
36
40
|
|
|
37
|
-
console.log(" [WASM] 3. instantiating...");
|
|
38
41
|
const instance = await WebAssembly.instantiate(module, {
|
|
39
|
-
wasi_snapshot_preview1: wasiStub
|
|
42
|
+
wasi_snapshot_preview1: wasiStub,
|
|
40
43
|
});
|
|
41
|
-
console.log(" [WASM] 4. instantiated OK");
|
|
42
|
-
console.log(" [WASM] exports:", Object.keys(instance.exports).join(", "));
|
|
43
44
|
|
|
44
45
|
const _init = instance.exports._initialize as (() => void) | undefined;
|
|
45
|
-
if (_init)
|
|
46
|
-
console.log(" [WASM] 5. calling _initialize...");
|
|
47
|
-
_init();
|
|
48
|
-
console.log(" [WASM] 6. _initialize done");
|
|
49
|
-
} else {
|
|
50
|
-
console.log(" [WASM] 5. no _initialize export, skipping");
|
|
51
|
-
}
|
|
46
|
+
if (_init) _init();
|
|
52
47
|
|
|
53
48
|
return instance;
|
|
54
49
|
}
|