payid-rule-engine 0.2.0 → 0.2.1

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 (3) hide show
  1. package/package.json +2 -2
  2. package/src/sandbox.ts +244 -34
  3. package/src/wasm.ts +15 -2
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "payid-rule-engine",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "build": "tsup src/sandbox.ts --format esm --dts --clean"
7
+ "build": "tsup src/* --format esm --dts --clean"
8
8
  },
9
9
  "dependencies": {
10
10
  "@types/lodash": "^4.17.21",
package/src/sandbox.ts CHANGED
@@ -1,52 +1,262 @@
1
- // sandbox.ts — WASI-free version
2
- // Lihat wasm.ts untuk penjelasan mengapa WASI dihapus
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.
3
5
 
4
6
  import type { RuleContext, RuleResult } from "payid-types";
5
- import { loadWasm } from "./wasm";
7
+
8
+ // ── Entry point (sama interface dengan runWasmRule) ───────────────────────────
6
9
 
7
10
  export async function runWasmRule(
8
- wasmBinary: Buffer,
11
+ _wasmBinary: Buffer, // ignored — pakai TS implementation
9
12
  context: RuleContext,
10
13
  config: any
11
14
  ): Promise<RuleResult> {
12
- // loadWasm tidak lagi butuh WASI instance
13
- const instance = await loadWasm(wasmBinary);
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
+ }
14
116
 
15
- const memory = instance.exports.memory as WebAssembly.Memory;
16
- const alloc = instance.exports.alloc as (size: number) => number;
17
- const free_ = instance.exports.free as (ptr: number, size: number) => void;
18
- const evaluate = instance.exports.evaluate as (
19
- a: number, b: number, c: number, d: number, e: number, f: number
20
- ) => number;
117
+ // ── Field resolution ──────────────────────────────────────────────────────────
21
118
 
22
- const ctxBuf = Buffer.from(JSON.stringify(context));
23
- const cfgBuf = Buffer.from(JSON.stringify(config));
24
- const OUT_SIZE = 4096;
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
+ }
25
123
 
26
- const ctxPtr = alloc(ctxBuf.length);
27
- const cfgPtr = alloc(cfgBuf.length);
28
- const outPtr = alloc(OUT_SIZE);
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
+ }
29
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 {
30
168
  try {
31
- new Uint8Array(memory.buffer).set(ctxBuf, ctxPtr);
32
- new Uint8Array(memory.buffer).set(cfgBuf, cfgPtr);
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 ─────────────────────────────────────────────────────────
33
194
 
34
- const rc = evaluate(
35
- ctxPtr, ctxBuf.length,
36
- cfgPtr, cfgBuf.length,
37
- outPtr, OUT_SIZE
38
- );
195
+ function applyOp(actual: any, op: string, expected: any): boolean {
196
+ const a = toU128(actual);
197
+ const b = toU128(expected);
39
198
 
40
- if (rc < 0) throw new Error(`WASM evaluate failed rc=${rc}`);
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;
41
204
 
42
- const out = Buffer.from(
43
- new Uint8Array(memory.buffer).slice(outPtr, outPtr + rc)
44
- );
205
+ case "==": return String(actual) === String(expected) || actual == expected;
206
+ case "!=": return String(actual) !== String(expected) && actual != expected;
45
207
 
46
- return JSON.parse(out.toString("utf8"));
47
- } finally {
48
- free_(ctxPtr, ctxBuf.length);
49
- free_(cfgPtr, cfgBuf.length);
50
- free_(outPtr, OUT_SIZE);
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;
51
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
+ });
52
262
  }
package/src/wasm.ts CHANGED
@@ -7,14 +7,18 @@
7
7
  // Solusi: pass minimal stub yang satisfy wasi_snapshot_preview1 interface,
8
8
  // cukup untuk Rust allocator init tanpa dependency ke WASI runtime.
9
9
 
10
+ // wasm.ts — WASI-free loader + DEBUG logging
11
+
10
12
  export async function loadWasm(
11
13
  binary: Buffer,
12
14
  ): Promise<WebAssembly.Instance> {
13
15
 
16
+ console.log(" [WASM] 1. compiling...");
14
17
  const module = await WebAssembly.compile(binary);
18
+ console.log(" [WASM] 2. compiled OK, binary size:", binary.length);
15
19
 
16
20
  const wasiStub: Record<string, (...args: any[]) => any> = {
17
- fd_write: () => 8, // EBADF
21
+ fd_write: () => 8,
18
22
  fd_read: () => 8,
19
23
  fd_close: () => 0,
20
24
  fd_seek: () => 8,
@@ -30,12 +34,21 @@ export async function loadWasm(
30
34
  random_get: () => 0,
31
35
  };
32
36
 
37
+ console.log(" [WASM] 3. instantiating...");
33
38
  const instance = await WebAssembly.instantiate(module, {
34
39
  wasi_snapshot_preview1: wasiStub
35
40
  });
41
+ console.log(" [WASM] 4. instantiated OK");
42
+ console.log(" [WASM] exports:", Object.keys(instance.exports).join(", "));
36
43
 
37
44
  const _init = instance.exports._initialize as (() => void) | undefined;
38
- if (_init) _init();
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
+ }
39
52
 
40
53
  return instance;
41
54
  }