rulit 1.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/LICENSE.md +21 -0
- package/README.md +545 -0
- package/dist/cli/ui-template.hbs +313 -0
- package/dist/cli/ui.d.mts +9 -0
- package/dist/cli/ui.d.ts +9 -0
- package/dist/cli/ui.js +311 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/cli/ui.mjs +277 -0
- package/dist/cli/ui.mjs.map +1 -0
- package/dist/index.d.mts +745 -0
- package/dist/index.d.ts +745 -0
- package/dist/index.js +1357 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1322 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +65 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1322 @@
|
|
|
1
|
+
// src/condition.ts
|
|
2
|
+
function condition(label, test, options) {
|
|
3
|
+
const meta = {
|
|
4
|
+
label,
|
|
5
|
+
reasonCode: typeof options === "object" ? options?.reasonCode : void 0,
|
|
6
|
+
kind: "atomic"
|
|
7
|
+
};
|
|
8
|
+
const cond = (facts) => {
|
|
9
|
+
const result = test(facts);
|
|
10
|
+
const details = typeof options === "function" ? options : options?.details;
|
|
11
|
+
const info = details ? details(facts) : void 0;
|
|
12
|
+
return {
|
|
13
|
+
label,
|
|
14
|
+
result,
|
|
15
|
+
left: info?.left,
|
|
16
|
+
op: info?.op,
|
|
17
|
+
right: info?.right,
|
|
18
|
+
reasonCode: meta.reasonCode
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
cond.meta = meta;
|
|
22
|
+
return cond;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/field.ts
|
|
26
|
+
function field(path) {
|
|
27
|
+
if (path === void 0) {
|
|
28
|
+
return (nextPath) => createField(nextPath);
|
|
29
|
+
}
|
|
30
|
+
return createField(path);
|
|
31
|
+
}
|
|
32
|
+
function createField(path) {
|
|
33
|
+
const getter = (facts) => getPathValue(facts, path);
|
|
34
|
+
const base = {
|
|
35
|
+
path,
|
|
36
|
+
get: getter,
|
|
37
|
+
eq: (value) => condition(
|
|
38
|
+
`${path} == ${String(value)}`,
|
|
39
|
+
(facts) => getter(facts) === value,
|
|
40
|
+
(facts) => ({
|
|
41
|
+
left: getter(facts),
|
|
42
|
+
op: "==",
|
|
43
|
+
right: value
|
|
44
|
+
})
|
|
45
|
+
),
|
|
46
|
+
in: (values) => condition(
|
|
47
|
+
`${path} in [${values.length}]`,
|
|
48
|
+
(facts) => values.includes(getter(facts)),
|
|
49
|
+
(facts) => ({
|
|
50
|
+
left: getter(facts),
|
|
51
|
+
op: "in",
|
|
52
|
+
right: values
|
|
53
|
+
})
|
|
54
|
+
)
|
|
55
|
+
};
|
|
56
|
+
return addOperators(base);
|
|
57
|
+
}
|
|
58
|
+
function addOperators(base) {
|
|
59
|
+
const anyBase = base;
|
|
60
|
+
anyBase.gt = (value) => condition(
|
|
61
|
+
`${base.path} > ${value}`,
|
|
62
|
+
(facts) => Number(base.get(facts)) > value,
|
|
63
|
+
(facts) => ({
|
|
64
|
+
left: base.get(facts),
|
|
65
|
+
op: ">",
|
|
66
|
+
right: value
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
anyBase.gte = (value) => condition(
|
|
70
|
+
`${base.path} >= ${value}`,
|
|
71
|
+
(facts) => Number(base.get(facts)) >= value,
|
|
72
|
+
(facts) => ({
|
|
73
|
+
left: base.get(facts),
|
|
74
|
+
op: ">=",
|
|
75
|
+
right: value
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
anyBase.lt = (value) => condition(
|
|
79
|
+
`${base.path} < ${value}`,
|
|
80
|
+
(facts) => Number(base.get(facts)) < value,
|
|
81
|
+
(facts) => ({
|
|
82
|
+
left: base.get(facts),
|
|
83
|
+
op: "<",
|
|
84
|
+
right: value
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
anyBase.lte = (value) => condition(
|
|
88
|
+
`${base.path} <= ${value}`,
|
|
89
|
+
(facts) => Number(base.get(facts)) <= value,
|
|
90
|
+
(facts) => ({
|
|
91
|
+
left: base.get(facts),
|
|
92
|
+
op: "<=",
|
|
93
|
+
right: value
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
anyBase.between = (min, max) => condition(
|
|
97
|
+
`${base.path} between ${min} and ${max}`,
|
|
98
|
+
(facts) => {
|
|
99
|
+
const value = Number(base.get(facts));
|
|
100
|
+
return value >= min && value <= max;
|
|
101
|
+
},
|
|
102
|
+
(facts) => ({
|
|
103
|
+
left: base.get(facts),
|
|
104
|
+
op: "between",
|
|
105
|
+
right: [min, max]
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
anyBase.contains = (value) => condition(
|
|
109
|
+
`${base.path} contains ${String(value)}`,
|
|
110
|
+
(facts) => {
|
|
111
|
+
const current = base.get(facts);
|
|
112
|
+
if (typeof current === "string") {
|
|
113
|
+
return current.includes(String(value));
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(current)) {
|
|
116
|
+
return current.includes(value);
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
},
|
|
120
|
+
(facts) => ({
|
|
121
|
+
left: base.get(facts),
|
|
122
|
+
op: "contains",
|
|
123
|
+
right: value
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
anyBase.startsWith = (value) => condition(
|
|
127
|
+
`${base.path} startsWith ${value}`,
|
|
128
|
+
(facts) => {
|
|
129
|
+
const current = base.get(facts);
|
|
130
|
+
return typeof current === "string" ? current.startsWith(value) : false;
|
|
131
|
+
},
|
|
132
|
+
(facts) => ({
|
|
133
|
+
left: base.get(facts),
|
|
134
|
+
op: "startsWith",
|
|
135
|
+
right: value
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
anyBase.matches = (value) => condition(
|
|
139
|
+
`${base.path} matches ${value.toString()}`,
|
|
140
|
+
(facts) => {
|
|
141
|
+
const current = base.get(facts);
|
|
142
|
+
return typeof current === "string" ? value.test(current) : false;
|
|
143
|
+
},
|
|
144
|
+
(facts) => ({
|
|
145
|
+
left: base.get(facts),
|
|
146
|
+
op: "matches",
|
|
147
|
+
right: value.toString()
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
anyBase.isTrue = () => condition(
|
|
151
|
+
`${base.path} is true`,
|
|
152
|
+
(facts) => base.get(facts) === true,
|
|
153
|
+
(facts) => ({
|
|
154
|
+
left: base.get(facts),
|
|
155
|
+
op: "is",
|
|
156
|
+
right: true
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
anyBase.isFalse = () => condition(
|
|
160
|
+
`${base.path} is false`,
|
|
161
|
+
(facts) => base.get(facts) === false,
|
|
162
|
+
(facts) => ({
|
|
163
|
+
left: base.get(facts),
|
|
164
|
+
op: "is",
|
|
165
|
+
right: false
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
anyBase.before = (value) => condition(
|
|
169
|
+
`${base.path} before ${value.toISOString()}`,
|
|
170
|
+
(facts) => {
|
|
171
|
+
const current = base.get(facts);
|
|
172
|
+
return current instanceof Date ? current.getTime() < value.getTime() : false;
|
|
173
|
+
},
|
|
174
|
+
(facts) => ({
|
|
175
|
+
left: base.get(facts),
|
|
176
|
+
op: "before",
|
|
177
|
+
right: value.toISOString()
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
anyBase.after = (value) => condition(
|
|
181
|
+
`${base.path} after ${value.toISOString()}`,
|
|
182
|
+
(facts) => {
|
|
183
|
+
const current = base.get(facts);
|
|
184
|
+
return current instanceof Date ? current.getTime() > value.getTime() : false;
|
|
185
|
+
},
|
|
186
|
+
(facts) => ({
|
|
187
|
+
left: base.get(facts),
|
|
188
|
+
op: "after",
|
|
189
|
+
right: value.toISOString()
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
anyBase.any = (predicate, label = `${base.path} any`) => condition(
|
|
193
|
+
label,
|
|
194
|
+
(facts) => {
|
|
195
|
+
const current = base.get(facts);
|
|
196
|
+
return Array.isArray(current) ? current.some(predicate) : false;
|
|
197
|
+
},
|
|
198
|
+
(facts) => ({
|
|
199
|
+
left: base.get(facts),
|
|
200
|
+
op: "any",
|
|
201
|
+
right: "predicate"
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
anyBase.all = (predicate, label = `${base.path} all`) => condition(
|
|
205
|
+
label,
|
|
206
|
+
(facts) => {
|
|
207
|
+
const current = base.get(facts);
|
|
208
|
+
return Array.isArray(current) ? current.every(predicate) : false;
|
|
209
|
+
},
|
|
210
|
+
(facts) => ({
|
|
211
|
+
left: base.get(facts),
|
|
212
|
+
op: "all",
|
|
213
|
+
right: "predicate"
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
return base;
|
|
217
|
+
}
|
|
218
|
+
function getPathValue(facts, path) {
|
|
219
|
+
const parts = String(path).split(".");
|
|
220
|
+
let current = facts;
|
|
221
|
+
for (const part of parts) {
|
|
222
|
+
if (current && typeof current === "object") {
|
|
223
|
+
current = current[part];
|
|
224
|
+
} else {
|
|
225
|
+
return void 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return current;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/op.ts
|
|
232
|
+
function resolveLabel(defaultLabel, label) {
|
|
233
|
+
if (!label) {
|
|
234
|
+
return defaultLabel;
|
|
235
|
+
}
|
|
236
|
+
return typeof label === "string" ? label : label.label;
|
|
237
|
+
}
|
|
238
|
+
var op = { and, or, not, custom, register, use, has, list };
|
|
239
|
+
var registry = {};
|
|
240
|
+
function and(...args) {
|
|
241
|
+
const { label, conditions } = splitArgs("and", args);
|
|
242
|
+
const meta = { label, kind: "and", children: conditions };
|
|
243
|
+
const cond = (facts) => {
|
|
244
|
+
const children = conditions.map((cond2) => cond2(facts));
|
|
245
|
+
const result = children.every((child) => child.result);
|
|
246
|
+
return {
|
|
247
|
+
label,
|
|
248
|
+
result,
|
|
249
|
+
op: "and",
|
|
250
|
+
left: children.map((child) => child.label),
|
|
251
|
+
right: void 0,
|
|
252
|
+
children
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
cond.meta = meta;
|
|
256
|
+
return cond;
|
|
257
|
+
}
|
|
258
|
+
function or(...args) {
|
|
259
|
+
const { label, conditions } = splitArgs("or", args);
|
|
260
|
+
const meta = { label, kind: "or", children: conditions };
|
|
261
|
+
const cond = (facts) => {
|
|
262
|
+
const children = conditions.map((cond2) => cond2(facts));
|
|
263
|
+
const result = children.some((child) => child.result);
|
|
264
|
+
return {
|
|
265
|
+
label,
|
|
266
|
+
result,
|
|
267
|
+
op: "or",
|
|
268
|
+
left: children.map((child) => child.label),
|
|
269
|
+
right: void 0,
|
|
270
|
+
children
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
cond.meta = meta;
|
|
274
|
+
return cond;
|
|
275
|
+
}
|
|
276
|
+
function not(labelOrCondition, maybeCondition) {
|
|
277
|
+
const label = resolveLabel(
|
|
278
|
+
"not",
|
|
279
|
+
typeof labelOrCondition === "function" ? void 0 : labelOrCondition
|
|
280
|
+
);
|
|
281
|
+
const conditionToNegate = typeof labelOrCondition === "function" ? labelOrCondition : maybeCondition;
|
|
282
|
+
const meta = {
|
|
283
|
+
label,
|
|
284
|
+
kind: "not",
|
|
285
|
+
children: [conditionToNegate]
|
|
286
|
+
};
|
|
287
|
+
const cond = (facts) => {
|
|
288
|
+
const child = conditionToNegate(facts);
|
|
289
|
+
return {
|
|
290
|
+
label,
|
|
291
|
+
result: !child.result,
|
|
292
|
+
op: "not",
|
|
293
|
+
left: child.label,
|
|
294
|
+
right: void 0,
|
|
295
|
+
children: [child]
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
cond.meta = meta;
|
|
299
|
+
return cond;
|
|
300
|
+
}
|
|
301
|
+
function custom(label, test, options) {
|
|
302
|
+
return condition(label, test, options);
|
|
303
|
+
}
|
|
304
|
+
function register(name, factory) {
|
|
305
|
+
if (registry[name]) {
|
|
306
|
+
throw new Error(`Operator "${name}" is already registered.`);
|
|
307
|
+
}
|
|
308
|
+
registry[name] = factory;
|
|
309
|
+
}
|
|
310
|
+
function use(name, ...args) {
|
|
311
|
+
const factory = registry[name];
|
|
312
|
+
if (!factory) {
|
|
313
|
+
throw new Error(`Operator "${name}" is not registered.`);
|
|
314
|
+
}
|
|
315
|
+
return factory(...args);
|
|
316
|
+
}
|
|
317
|
+
function has(name) {
|
|
318
|
+
return Boolean(registry[name]);
|
|
319
|
+
}
|
|
320
|
+
function list() {
|
|
321
|
+
return Object.keys(registry).sort();
|
|
322
|
+
}
|
|
323
|
+
function splitArgs(defaultLabel, args) {
|
|
324
|
+
if (args.length === 0) {
|
|
325
|
+
return { label: defaultLabel, conditions: [] };
|
|
326
|
+
}
|
|
327
|
+
const [first, ...rest] = args;
|
|
328
|
+
if (typeof first === "function") {
|
|
329
|
+
return { label: defaultLabel, conditions: args };
|
|
330
|
+
}
|
|
331
|
+
const conditions = rest;
|
|
332
|
+
return {
|
|
333
|
+
label: resolveLabel(defaultLabel, first),
|
|
334
|
+
conditions
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/trace.ts
|
|
339
|
+
function explainTrace(trace, name) {
|
|
340
|
+
const header = name ? `Ruleset ${name}` : "Ruleset";
|
|
341
|
+
const lines = [header];
|
|
342
|
+
for (const rule of trace) {
|
|
343
|
+
const tags = rule.meta?.tags?.length ? ` [tags: ${rule.meta.tags.join(", ")}]` : "";
|
|
344
|
+
const reason = rule.meta?.reasonCode ? ` [reason: ${rule.meta.reasonCode}]` : "";
|
|
345
|
+
const skipped = rule.skippedReason ? ` (skipped: ${rule.skippedReason})` : "";
|
|
346
|
+
lines.push(
|
|
347
|
+
`- Rule ${rule.ruleId}: ${rule.matched ? "matched" : "skipped"}${skipped}${tags}${reason}`
|
|
348
|
+
);
|
|
349
|
+
for (const condition2 of rule.conditions) {
|
|
350
|
+
lines.push(...renderCondition(condition2, 2));
|
|
351
|
+
}
|
|
352
|
+
for (const note of rule.notes) {
|
|
353
|
+
lines.push(` - note: ${note}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return lines.join("\n");
|
|
357
|
+
}
|
|
358
|
+
function formatCondition(condition2) {
|
|
359
|
+
const parts = [`[${condition2.result ? "true" : "false"}]`, condition2.label];
|
|
360
|
+
if (condition2.reasonCode) {
|
|
361
|
+
parts.push(`{reason: ${condition2.reasonCode}}`);
|
|
362
|
+
}
|
|
363
|
+
if (condition2.op) {
|
|
364
|
+
const left = formatValue(condition2.left);
|
|
365
|
+
const right = formatValue(condition2.right);
|
|
366
|
+
parts.push(`(${left} ${condition2.op} ${right})`);
|
|
367
|
+
}
|
|
368
|
+
return parts.join(" ");
|
|
369
|
+
}
|
|
370
|
+
function renderCondition(condition2, indent) {
|
|
371
|
+
const pad = " ".repeat(indent);
|
|
372
|
+
const lines = [`${pad}- ${formatCondition(condition2)}`];
|
|
373
|
+
if (condition2.children && condition2.children.length > 0) {
|
|
374
|
+
for (const child of condition2.children) {
|
|
375
|
+
lines.push(...renderCondition(child, indent + 2));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return lines;
|
|
379
|
+
}
|
|
380
|
+
function formatValue(value) {
|
|
381
|
+
if (typeof value === "string") {
|
|
382
|
+
return `"${value}"`;
|
|
383
|
+
}
|
|
384
|
+
return String(value);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/registry.ts
|
|
388
|
+
var entries = /* @__PURE__ */ new Map();
|
|
389
|
+
var nameIndex = /* @__PURE__ */ new Map();
|
|
390
|
+
var counter = 0;
|
|
391
|
+
var traceCounter = 0;
|
|
392
|
+
var traceLimit = 25;
|
|
393
|
+
function makeId() {
|
|
394
|
+
return `ruleset-${counter++}`;
|
|
395
|
+
}
|
|
396
|
+
var registry2 = {
|
|
397
|
+
/**
|
|
398
|
+
* Register a ruleset in the global registry.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```ts
|
|
402
|
+
* const rs = Rules.ruleset<Facts, Effects>("eligibility");
|
|
403
|
+
* Rules.registry.list();
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
register(source, name) {
|
|
407
|
+
const id = makeId();
|
|
408
|
+
entries.set(id, { id, name, createdAt: Date.now(), source, traces: [] });
|
|
409
|
+
if (name) {
|
|
410
|
+
nameIndex.set(name, id);
|
|
411
|
+
}
|
|
412
|
+
return id;
|
|
413
|
+
},
|
|
414
|
+
/**
|
|
415
|
+
* List all registered rulesets.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* const list = Rules.registry.list();
|
|
420
|
+
* ```
|
|
421
|
+
*/
|
|
422
|
+
list() {
|
|
423
|
+
return Array.from(entries.values()).map(({ id, name, createdAt }) => ({
|
|
424
|
+
id,
|
|
425
|
+
name,
|
|
426
|
+
createdAt
|
|
427
|
+
}));
|
|
428
|
+
},
|
|
429
|
+
/**
|
|
430
|
+
* Get a graph by id or ruleset name.
|
|
431
|
+
*
|
|
432
|
+
* @example
|
|
433
|
+
* ```ts
|
|
434
|
+
* const graph = Rules.registry.getGraph("eligibility");
|
|
435
|
+
* ```
|
|
436
|
+
*/
|
|
437
|
+
getGraph(idOrName) {
|
|
438
|
+
const entry = getEntry(idOrName);
|
|
439
|
+
return entry?.source.graph();
|
|
440
|
+
},
|
|
441
|
+
/**
|
|
442
|
+
* Get Mermaid output by id or ruleset name.
|
|
443
|
+
*
|
|
444
|
+
* @example
|
|
445
|
+
* ```ts
|
|
446
|
+
* const mermaid = Rules.registry.getMermaid("eligibility");
|
|
447
|
+
* ```
|
|
448
|
+
*/
|
|
449
|
+
getMermaid(idOrName) {
|
|
450
|
+
const entry = getEntry(idOrName);
|
|
451
|
+
return entry?.source.toMermaid();
|
|
452
|
+
},
|
|
453
|
+
/**
|
|
454
|
+
* Record a trace run for a ruleset. Keeps a rolling window of traces.
|
|
455
|
+
*
|
|
456
|
+
* @example
|
|
457
|
+
* ```ts
|
|
458
|
+
* Rules.registry.recordTrace("eligibility", trace, fired);
|
|
459
|
+
* ```
|
|
460
|
+
*/
|
|
461
|
+
recordTrace(idOrName, trace, fired, facts) {
|
|
462
|
+
const entry = getEntry(idOrName);
|
|
463
|
+
if (!entry) {
|
|
464
|
+
return void 0;
|
|
465
|
+
}
|
|
466
|
+
const run = {
|
|
467
|
+
id: `trace-${traceCounter++}`,
|
|
468
|
+
createdAt: Date.now(),
|
|
469
|
+
facts,
|
|
470
|
+
fired,
|
|
471
|
+
trace
|
|
472
|
+
};
|
|
473
|
+
entry.traces.push(run);
|
|
474
|
+
if (entry.traces.length > traceLimit) {
|
|
475
|
+
entry.traces.splice(0, entry.traces.length - traceLimit);
|
|
476
|
+
}
|
|
477
|
+
return run;
|
|
478
|
+
},
|
|
479
|
+
/**
|
|
480
|
+
* List trace runs for a ruleset.
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
* ```ts
|
|
484
|
+
* const traces = Rules.registry.listTraces("eligibility");
|
|
485
|
+
* ```
|
|
486
|
+
*/
|
|
487
|
+
listTraces(idOrName) {
|
|
488
|
+
const entry = getEntry(idOrName);
|
|
489
|
+
return entry ? [...entry.traces] : [];
|
|
490
|
+
},
|
|
491
|
+
/**
|
|
492
|
+
* Get a trace by id for a ruleset.
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* ```ts
|
|
496
|
+
* const trace = Rules.registry.getTrace("eligibility", "trace-0");
|
|
497
|
+
* ```
|
|
498
|
+
*/
|
|
499
|
+
getTrace(idOrName, traceId) {
|
|
500
|
+
const entry = getEntry(idOrName);
|
|
501
|
+
return entry?.traces.find((run) => run.id === traceId);
|
|
502
|
+
},
|
|
503
|
+
/**
|
|
504
|
+
* Clear the registry (useful in tests).
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* ```ts
|
|
508
|
+
* Rules.registry.clear();
|
|
509
|
+
* ```
|
|
510
|
+
*/
|
|
511
|
+
clear() {
|
|
512
|
+
entries.clear();
|
|
513
|
+
nameIndex.clear();
|
|
514
|
+
traceCounter = 0;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
function getEntry(idOrName) {
|
|
518
|
+
const byId = entries.get(idOrName);
|
|
519
|
+
if (byId) {
|
|
520
|
+
return byId;
|
|
521
|
+
}
|
|
522
|
+
const id = nameIndex.get(idOrName);
|
|
523
|
+
return id ? entries.get(id) : void 0;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/ruleset.ts
|
|
527
|
+
var RulesetBuilderImpl = class {
|
|
528
|
+
constructor(name) {
|
|
529
|
+
this.rules = [];
|
|
530
|
+
this.nextOrder = 0;
|
|
531
|
+
this.name = name;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Set the default effects factory. Required before compile().
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* ```ts
|
|
538
|
+
* const rs = Rules.ruleset<Facts, Effects>("rs")
|
|
539
|
+
* .defaultEffects(() => ({ flags: [] }));
|
|
540
|
+
* ```
|
|
541
|
+
*/
|
|
542
|
+
defaultEffects(factory) {
|
|
543
|
+
this.defaultEffectsFactory = factory;
|
|
544
|
+
return this;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Provide a validation function for facts. Called before each run.
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* ```ts
|
|
551
|
+
* const rs = Rules.ruleset<Facts, Effects>("validate")
|
|
552
|
+
* .validateFacts((facts) => {
|
|
553
|
+
* if (!facts.user) throw new Error("missing user");
|
|
554
|
+
* });
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
validateFacts(validator) {
|
|
558
|
+
this.validateFactsFn = validator;
|
|
559
|
+
return this;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Provide a validation function for effects. Called after default effects creation
|
|
563
|
+
* and after each run completes.
|
|
564
|
+
*
|
|
565
|
+
* @example
|
|
566
|
+
* ```ts
|
|
567
|
+
* const rs = Rules.ruleset<Facts, Effects>("validate")
|
|
568
|
+
* .validateEffects((effects) => {
|
|
569
|
+
* if (!Array.isArray(effects.flags)) throw new Error("invalid effects");
|
|
570
|
+
* });
|
|
571
|
+
* ```
|
|
572
|
+
*/
|
|
573
|
+
validateEffects(validator) {
|
|
574
|
+
this.validateEffectsFn = validator;
|
|
575
|
+
return this;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Attach telemetry adapter (OpenTelemetry-compatible).
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* ```ts
|
|
582
|
+
* const adapter = Rules.otel.createAdapter(trace.getTracer("rulit"));
|
|
583
|
+
* Rules.ruleset("rs").telemetry(adapter);
|
|
584
|
+
* ```
|
|
585
|
+
*/
|
|
586
|
+
telemetry(adapter) {
|
|
587
|
+
this.telemetryAdapter = adapter;
|
|
588
|
+
return this;
|
|
589
|
+
}
|
|
590
|
+
_setRegistryId(id) {
|
|
591
|
+
this.registryId = id;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Add a new rule to the ruleset.
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```ts
|
|
598
|
+
* Rules.ruleset<Facts, Effects>("rs")
|
|
599
|
+
* .defaultEffects(() => ({ flags: [] }))
|
|
600
|
+
* .rule("vip")
|
|
601
|
+
* .when(factsField("user.tags").contains("vip"))
|
|
602
|
+
* .then(({ effects }) => effects.flags.push("vip"))
|
|
603
|
+
* .end();
|
|
604
|
+
* ```
|
|
605
|
+
*/
|
|
606
|
+
rule(id) {
|
|
607
|
+
return new RuleBuilderImpl(this, id);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Create a typed field helper bound to the facts type.
|
|
611
|
+
*
|
|
612
|
+
* @example
|
|
613
|
+
* ```ts
|
|
614
|
+
* const factsField = Rules.ruleset<Facts, Effects>("rs").field();
|
|
615
|
+
* const isAdult = factsField("user.age").gte(18);
|
|
616
|
+
* ```
|
|
617
|
+
*/
|
|
618
|
+
field() {
|
|
619
|
+
return field();
|
|
620
|
+
}
|
|
621
|
+
_addRule(rule) {
|
|
622
|
+
this.rules.push(rule);
|
|
623
|
+
}
|
|
624
|
+
_nextOrder() {
|
|
625
|
+
const order = this.nextOrder;
|
|
626
|
+
this.nextOrder += 1;
|
|
627
|
+
return order;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Export a graph representation of the ruleset.
|
|
631
|
+
*
|
|
632
|
+
* @example
|
|
633
|
+
* ```ts
|
|
634
|
+
* const graph = ruleset.graph();
|
|
635
|
+
* ```
|
|
636
|
+
*/
|
|
637
|
+
graph() {
|
|
638
|
+
const nodes = [];
|
|
639
|
+
const edges = [];
|
|
640
|
+
const rulesetId = `ruleset:${this.name ?? "ruleset"}`;
|
|
641
|
+
nodes.push({
|
|
642
|
+
id: rulesetId,
|
|
643
|
+
type: "ruleset",
|
|
644
|
+
label: this.name ?? "ruleset"
|
|
645
|
+
});
|
|
646
|
+
let conditionCounter = 0;
|
|
647
|
+
const visitCondition = (condition2, parentId) => {
|
|
648
|
+
const meta = condition2.meta;
|
|
649
|
+
const conditionId = `condition:${conditionCounter++}`;
|
|
650
|
+
nodes.push({
|
|
651
|
+
id: conditionId,
|
|
652
|
+
type: "condition",
|
|
653
|
+
label: meta?.label ?? "condition",
|
|
654
|
+
reasonCode: meta?.reasonCode
|
|
655
|
+
});
|
|
656
|
+
edges.push({ from: parentId, to: conditionId });
|
|
657
|
+
if (meta?.children && meta.children.length > 0) {
|
|
658
|
+
for (const child of meta.children) {
|
|
659
|
+
visitCondition(child, conditionId);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
for (const rule of this.rules) {
|
|
664
|
+
const ruleId = `rule:${rule.id}`;
|
|
665
|
+
nodes.push({
|
|
666
|
+
id: ruleId,
|
|
667
|
+
type: "rule",
|
|
668
|
+
label: rule.id,
|
|
669
|
+
reasonCode: rule.meta?.reasonCode,
|
|
670
|
+
tags: rule.meta?.tags,
|
|
671
|
+
description: rule.meta?.description,
|
|
672
|
+
version: rule.meta?.version
|
|
673
|
+
});
|
|
674
|
+
edges.push({ from: rulesetId, to: ruleId });
|
|
675
|
+
for (const condition2 of rule.conditions) {
|
|
676
|
+
visitCondition(condition2, ruleId);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { nodes, edges };
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Export a Mermaid flowchart for visualization.
|
|
683
|
+
*
|
|
684
|
+
* @example
|
|
685
|
+
* ```ts
|
|
686
|
+
* const mermaid = ruleset.toMermaid();
|
|
687
|
+
* ```
|
|
688
|
+
*/
|
|
689
|
+
toMermaid() {
|
|
690
|
+
const graph = this.graph();
|
|
691
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
692
|
+
let counter2 = 0;
|
|
693
|
+
for (const node of graph.nodes) {
|
|
694
|
+
idMap.set(node.id, `n${counter2++}`);
|
|
695
|
+
}
|
|
696
|
+
const lines = ["flowchart TD"];
|
|
697
|
+
for (const node of graph.nodes) {
|
|
698
|
+
const id = idMap.get(node.id);
|
|
699
|
+
if (!id) {
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
const reason = node.reasonCode ? ` [reason: ${node.reasonCode}]` : "";
|
|
703
|
+
const tags = node.tags?.length ? ` [tags: ${node.tags.join(", ")}]` : "";
|
|
704
|
+
const label = `${capitalize(node.type)}: ${node.label}${tags}${reason}`;
|
|
705
|
+
lines.push(` ${id}["${label}"]`);
|
|
706
|
+
}
|
|
707
|
+
for (const edge of graph.edges) {
|
|
708
|
+
const from = idMap.get(edge.from);
|
|
709
|
+
const to = idMap.get(edge.to);
|
|
710
|
+
if (from && to) {
|
|
711
|
+
lines.push(` ${from} --> ${to}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return lines.join("\n");
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Compile the ruleset into an engine.
|
|
718
|
+
*
|
|
719
|
+
* @example
|
|
720
|
+
* ```ts
|
|
721
|
+
* const engine = Rules.ruleset<Facts, Effects>("rs")
|
|
722
|
+
* .defaultEffects(() => ({ flags: [] }))
|
|
723
|
+
* .compile();
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
compile() {
|
|
727
|
+
const defaultEffectsFactory = this.defaultEffectsFactory;
|
|
728
|
+
if (!defaultEffectsFactory) {
|
|
729
|
+
throw new Error("defaultEffects() is required before compile().");
|
|
730
|
+
}
|
|
731
|
+
const sortedRules = [...this.rules].sort((a, b) => {
|
|
732
|
+
if (a.priority !== b.priority) {
|
|
733
|
+
return b.priority - a.priority;
|
|
734
|
+
}
|
|
735
|
+
return a.order - b.order;
|
|
736
|
+
});
|
|
737
|
+
return {
|
|
738
|
+
run: ({
|
|
739
|
+
facts,
|
|
740
|
+
activation = "all",
|
|
741
|
+
effectsMode = "mutable",
|
|
742
|
+
mergeStrategy = "assign",
|
|
743
|
+
rollbackOnError = false,
|
|
744
|
+
includeTags,
|
|
745
|
+
excludeTags
|
|
746
|
+
}) => {
|
|
747
|
+
return runWithSpan(
|
|
748
|
+
this.telemetryAdapter,
|
|
749
|
+
"rulit.run",
|
|
750
|
+
rulesetSpanAttrs(this.name, this.registryId),
|
|
751
|
+
() => runSync({
|
|
752
|
+
facts,
|
|
753
|
+
activation,
|
|
754
|
+
effectsMode,
|
|
755
|
+
mergeStrategy,
|
|
756
|
+
rollbackOnError,
|
|
757
|
+
includeTags,
|
|
758
|
+
excludeTags,
|
|
759
|
+
rules: sortedRules,
|
|
760
|
+
defaultEffectsFactory,
|
|
761
|
+
validateFactsFn: this.validateFactsFn,
|
|
762
|
+
validateEffectsFn: this.validateEffectsFn,
|
|
763
|
+
registryId: this.registryId,
|
|
764
|
+
rulesetName: this.name,
|
|
765
|
+
telemetryAdapter: this.telemetryAdapter
|
|
766
|
+
})
|
|
767
|
+
);
|
|
768
|
+
},
|
|
769
|
+
runAsync: async ({
|
|
770
|
+
facts,
|
|
771
|
+
activation = "all",
|
|
772
|
+
effectsMode = "mutable",
|
|
773
|
+
mergeStrategy = "assign",
|
|
774
|
+
rollbackOnError = false,
|
|
775
|
+
includeTags,
|
|
776
|
+
excludeTags
|
|
777
|
+
}) => {
|
|
778
|
+
return runWithSpanAsync(
|
|
779
|
+
this.telemetryAdapter,
|
|
780
|
+
"rulit.run",
|
|
781
|
+
rulesetSpanAttrs(this.name, this.registryId),
|
|
782
|
+
() => runAsync({
|
|
783
|
+
facts,
|
|
784
|
+
activation,
|
|
785
|
+
effectsMode,
|
|
786
|
+
mergeStrategy,
|
|
787
|
+
rollbackOnError,
|
|
788
|
+
includeTags,
|
|
789
|
+
excludeTags,
|
|
790
|
+
rules: sortedRules,
|
|
791
|
+
defaultEffectsFactory,
|
|
792
|
+
validateFactsFn: this.validateFactsFn,
|
|
793
|
+
validateEffectsFn: this.validateEffectsFn,
|
|
794
|
+
registryId: this.registryId,
|
|
795
|
+
rulesetName: this.name,
|
|
796
|
+
telemetryAdapter: this.telemetryAdapter
|
|
797
|
+
})
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
var RuleBuilderImpl = class {
|
|
804
|
+
constructor(ruleset2, id) {
|
|
805
|
+
this.priorityValue = 0;
|
|
806
|
+
this.conditions = [];
|
|
807
|
+
this.metaValue = {};
|
|
808
|
+
this.ruleset = ruleset2;
|
|
809
|
+
this.id = id;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Set rule priority. Higher runs first.
|
|
813
|
+
*
|
|
814
|
+
* @example
|
|
815
|
+
* ```ts
|
|
816
|
+
* rule.priority(100);
|
|
817
|
+
* ```
|
|
818
|
+
*/
|
|
819
|
+
priority(value) {
|
|
820
|
+
this.priorityValue = value;
|
|
821
|
+
return this;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Add conditions to a rule.
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* ```ts
|
|
828
|
+
* rule.when(factsField("user.age").gte(18), factsField("user.tags").contains("vip"));
|
|
829
|
+
* ```
|
|
830
|
+
*/
|
|
831
|
+
when(...conditions) {
|
|
832
|
+
this.conditions = conditions;
|
|
833
|
+
return this;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Set rule metadata in one call.
|
|
837
|
+
*
|
|
838
|
+
* @example
|
|
839
|
+
* ```ts
|
|
840
|
+
* rule.meta({ tags: ["vip"], version: "1.0.0", reasonCode: "VIP_RULE" });
|
|
841
|
+
* ```
|
|
842
|
+
*/
|
|
843
|
+
meta(meta) {
|
|
844
|
+
const tags = meta.tags ?? this.metaValue.tags;
|
|
845
|
+
this.metaValue = { ...this.metaValue, ...meta, tags };
|
|
846
|
+
return this;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Set rule tags used for filtering.
|
|
850
|
+
*
|
|
851
|
+
* @example
|
|
852
|
+
* ```ts
|
|
853
|
+
* rule.tags("vip", "adult");
|
|
854
|
+
* ```
|
|
855
|
+
*/
|
|
856
|
+
tags(...tags) {
|
|
857
|
+
this.metaValue = { ...this.metaValue, tags };
|
|
858
|
+
return this;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Set a human-readable description.
|
|
862
|
+
*
|
|
863
|
+
* @example
|
|
864
|
+
* ```ts
|
|
865
|
+
* rule.description("VIP adult rule");
|
|
866
|
+
* ```
|
|
867
|
+
*/
|
|
868
|
+
description(description) {
|
|
869
|
+
this.metaValue = { ...this.metaValue, description };
|
|
870
|
+
return this;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Set a rule version string.
|
|
874
|
+
*
|
|
875
|
+
* @example
|
|
876
|
+
* ```ts
|
|
877
|
+
* rule.version("1.2.3");
|
|
878
|
+
* ```
|
|
879
|
+
*/
|
|
880
|
+
version(version) {
|
|
881
|
+
this.metaValue = { ...this.metaValue, version };
|
|
882
|
+
return this;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Set a reason code for audit/explain output.
|
|
886
|
+
*
|
|
887
|
+
* @example
|
|
888
|
+
* ```ts
|
|
889
|
+
* rule.reasonCode("VIP_ADULT");
|
|
890
|
+
* ```
|
|
891
|
+
*/
|
|
892
|
+
reasonCode(reasonCode) {
|
|
893
|
+
this.metaValue = { ...this.metaValue, reasonCode };
|
|
894
|
+
return this;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Enable or disable a rule.
|
|
898
|
+
*
|
|
899
|
+
* @example
|
|
900
|
+
* ```ts
|
|
901
|
+
* rule.enabled(false);
|
|
902
|
+
* ```
|
|
903
|
+
*/
|
|
904
|
+
enabled(enabled) {
|
|
905
|
+
this.metaValue = { ...this.metaValue, enabled };
|
|
906
|
+
return this;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Set the rule action. Returning a partial effects object applies a patch.
|
|
910
|
+
*
|
|
911
|
+
* @example
|
|
912
|
+
* ```ts
|
|
913
|
+
* rule.then(({ effects }) => {
|
|
914
|
+
* effects.flags.push("vip");
|
|
915
|
+
* });
|
|
916
|
+
* ```
|
|
917
|
+
*/
|
|
918
|
+
then(action) {
|
|
919
|
+
this.action = action;
|
|
920
|
+
return this;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Set an async rule action. Returning a partial effects object applies a patch.
|
|
924
|
+
*
|
|
925
|
+
* @example
|
|
926
|
+
* ```ts
|
|
927
|
+
* rule.thenAsync(async ({ effects }) => {
|
|
928
|
+
* effects.flags.push("vip");
|
|
929
|
+
* });
|
|
930
|
+
* ```
|
|
931
|
+
*/
|
|
932
|
+
thenAsync(action) {
|
|
933
|
+
this.action = action;
|
|
934
|
+
return this;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Finalize the rule and return to the ruleset builder.
|
|
938
|
+
*
|
|
939
|
+
* @example
|
|
940
|
+
* ```ts
|
|
941
|
+
* ruleset.rule("vip").then(() => undefined).end();
|
|
942
|
+
* ```
|
|
943
|
+
*/
|
|
944
|
+
end() {
|
|
945
|
+
if (!this.action) {
|
|
946
|
+
throw new Error("then() is required before end().");
|
|
947
|
+
}
|
|
948
|
+
const rule = {
|
|
949
|
+
id: this.id,
|
|
950
|
+
priority: this.priorityValue,
|
|
951
|
+
order: this.ruleset._nextOrder(),
|
|
952
|
+
conditions: this.conditions,
|
|
953
|
+
action: this.action,
|
|
954
|
+
meta: Object.keys(this.metaValue).length ? this.metaValue : void 0
|
|
955
|
+
};
|
|
956
|
+
this.ruleset._addRule(rule);
|
|
957
|
+
return this.ruleset;
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
function ruleset(name) {
|
|
961
|
+
const builder = new RulesetBuilderImpl(name);
|
|
962
|
+
const registryId = registry2.register(builder, name);
|
|
963
|
+
builder._setRegistryId(registryId);
|
|
964
|
+
return builder;
|
|
965
|
+
}
|
|
966
|
+
function getSkipReason(meta, includeTags, excludeTags) {
|
|
967
|
+
if (meta?.enabled === false) {
|
|
968
|
+
return "disabled";
|
|
969
|
+
}
|
|
970
|
+
const tags = meta?.tags ?? [];
|
|
971
|
+
if (includeTags && includeTags.length > 0 && !includeTags.some((tag) => tags.includes(tag))) {
|
|
972
|
+
return "tag-filtered";
|
|
973
|
+
}
|
|
974
|
+
if (excludeTags && excludeTags.length > 0 && excludeTags.some((tag) => tags.includes(tag))) {
|
|
975
|
+
return "tag-excluded";
|
|
976
|
+
}
|
|
977
|
+
return void 0;
|
|
978
|
+
}
|
|
979
|
+
function mergeEffects(target, patch, strategy) {
|
|
980
|
+
if (strategy === "assign") {
|
|
981
|
+
Object.assign(target, patch);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
deepMerge(target, patch);
|
|
985
|
+
}
|
|
986
|
+
function deepMerge(target, patch) {
|
|
987
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
988
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
989
|
+
const current = target[key];
|
|
990
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
991
|
+
target[key] = {};
|
|
992
|
+
}
|
|
993
|
+
deepMerge(target[key], value);
|
|
994
|
+
} else {
|
|
995
|
+
target[key] = value;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function cloneEffects(effects) {
|
|
1000
|
+
if (typeof structuredClone === "function") {
|
|
1001
|
+
return structuredClone(effects);
|
|
1002
|
+
}
|
|
1003
|
+
return JSON.parse(JSON.stringify(effects));
|
|
1004
|
+
}
|
|
1005
|
+
function runSync({
|
|
1006
|
+
facts,
|
|
1007
|
+
activation,
|
|
1008
|
+
effectsMode,
|
|
1009
|
+
mergeStrategy,
|
|
1010
|
+
rollbackOnError,
|
|
1011
|
+
includeTags,
|
|
1012
|
+
excludeTags,
|
|
1013
|
+
rules,
|
|
1014
|
+
defaultEffectsFactory,
|
|
1015
|
+
validateFactsFn,
|
|
1016
|
+
validateEffectsFn,
|
|
1017
|
+
registryId,
|
|
1018
|
+
rulesetName,
|
|
1019
|
+
telemetryAdapter
|
|
1020
|
+
}) {
|
|
1021
|
+
validateFactsFn?.(facts);
|
|
1022
|
+
let effects = defaultEffectsFactory();
|
|
1023
|
+
validateEffectsFn?.(effects);
|
|
1024
|
+
const trace = [];
|
|
1025
|
+
const fired = [];
|
|
1026
|
+
for (const rule of rules) {
|
|
1027
|
+
const start = Date.now();
|
|
1028
|
+
const ruleTrace = {
|
|
1029
|
+
ruleId: rule.id,
|
|
1030
|
+
matched: false,
|
|
1031
|
+
conditions: [],
|
|
1032
|
+
notes: [],
|
|
1033
|
+
meta: rule.meta
|
|
1034
|
+
};
|
|
1035
|
+
const skipReason = getSkipReason(rule.meta, includeTags, excludeTags);
|
|
1036
|
+
if (skipReason) {
|
|
1037
|
+
ruleTrace.skippedReason = skipReason;
|
|
1038
|
+
ruleTrace.durationMs = Date.now() - start;
|
|
1039
|
+
trace.push(ruleTrace);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
let matched = true;
|
|
1043
|
+
for (const condition2 of rule.conditions) {
|
|
1044
|
+
const conditionTrace = runWithSpan(
|
|
1045
|
+
telemetryAdapter,
|
|
1046
|
+
"rulit.condition",
|
|
1047
|
+
conditionSpanAttrs(rulesetName, rule, condition2),
|
|
1048
|
+
() => condition2(facts)
|
|
1049
|
+
);
|
|
1050
|
+
ruleTrace.conditions.push(conditionTrace);
|
|
1051
|
+
if (!conditionTrace.result) {
|
|
1052
|
+
matched = false;
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
ruleTrace.matched = matched;
|
|
1057
|
+
if (matched) {
|
|
1058
|
+
const workingEffects = effectsMode === "immutable" ? cloneEffects(effects) : effects;
|
|
1059
|
+
const ctx = {
|
|
1060
|
+
facts,
|
|
1061
|
+
effects: workingEffects,
|
|
1062
|
+
trace: {
|
|
1063
|
+
note: (message) => {
|
|
1064
|
+
ruleTrace.notes.push(message);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
try {
|
|
1069
|
+
const patch = runWithSpan(
|
|
1070
|
+
telemetryAdapter,
|
|
1071
|
+
"rulit.rule",
|
|
1072
|
+
ruleSpanAttrs(rulesetName, rule),
|
|
1073
|
+
() => rule.action(ctx)
|
|
1074
|
+
);
|
|
1075
|
+
if (isPromise(patch)) {
|
|
1076
|
+
throw new Error("Async rule action detected. Use runAsync().");
|
|
1077
|
+
}
|
|
1078
|
+
if (patch && typeof patch === "object") {
|
|
1079
|
+
mergeEffects(workingEffects, patch, mergeStrategy);
|
|
1080
|
+
}
|
|
1081
|
+
if (effectsMode === "immutable") {
|
|
1082
|
+
effects = workingEffects;
|
|
1083
|
+
}
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
ruleTrace.error = String(error);
|
|
1086
|
+
ruleTrace.notes.push(`error: ${String(error)}`);
|
|
1087
|
+
if (!rollbackOnError) {
|
|
1088
|
+
throw error;
|
|
1089
|
+
}
|
|
1090
|
+
if (effectsMode === "immutable") {
|
|
1091
|
+
effects = effects;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
fired.push(rule.id);
|
|
1095
|
+
}
|
|
1096
|
+
ruleTrace.durationMs = Date.now() - start;
|
|
1097
|
+
trace.push(ruleTrace);
|
|
1098
|
+
if (matched && activation === "first") {
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
validateEffectsFn?.(effects);
|
|
1103
|
+
if (registryId) {
|
|
1104
|
+
registry2.recordTrace(registryId, trace, fired, facts);
|
|
1105
|
+
}
|
|
1106
|
+
return {
|
|
1107
|
+
effects,
|
|
1108
|
+
fired,
|
|
1109
|
+
trace,
|
|
1110
|
+
explain: () => explainTrace(trace, rulesetName)
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
async function runAsync({
|
|
1114
|
+
facts,
|
|
1115
|
+
activation,
|
|
1116
|
+
effectsMode,
|
|
1117
|
+
mergeStrategy,
|
|
1118
|
+
rollbackOnError,
|
|
1119
|
+
includeTags,
|
|
1120
|
+
excludeTags,
|
|
1121
|
+
rules,
|
|
1122
|
+
defaultEffectsFactory,
|
|
1123
|
+
validateFactsFn,
|
|
1124
|
+
validateEffectsFn,
|
|
1125
|
+
registryId,
|
|
1126
|
+
rulesetName,
|
|
1127
|
+
telemetryAdapter
|
|
1128
|
+
}) {
|
|
1129
|
+
validateFactsFn?.(facts);
|
|
1130
|
+
let effects = defaultEffectsFactory();
|
|
1131
|
+
validateEffectsFn?.(effects);
|
|
1132
|
+
const trace = [];
|
|
1133
|
+
const fired = [];
|
|
1134
|
+
for (const rule of rules) {
|
|
1135
|
+
const start = Date.now();
|
|
1136
|
+
const ruleTrace = {
|
|
1137
|
+
ruleId: rule.id,
|
|
1138
|
+
matched: false,
|
|
1139
|
+
conditions: [],
|
|
1140
|
+
notes: [],
|
|
1141
|
+
meta: rule.meta
|
|
1142
|
+
};
|
|
1143
|
+
const skipReason = getSkipReason(rule.meta, includeTags, excludeTags);
|
|
1144
|
+
if (skipReason) {
|
|
1145
|
+
ruleTrace.skippedReason = skipReason;
|
|
1146
|
+
ruleTrace.durationMs = Date.now() - start;
|
|
1147
|
+
trace.push(ruleTrace);
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
let matched = true;
|
|
1151
|
+
for (const condition2 of rule.conditions) {
|
|
1152
|
+
const conditionTrace = runWithSpan(
|
|
1153
|
+
telemetryAdapter,
|
|
1154
|
+
"rulit.condition",
|
|
1155
|
+
conditionSpanAttrs(rulesetName, rule, condition2),
|
|
1156
|
+
() => condition2(facts)
|
|
1157
|
+
);
|
|
1158
|
+
ruleTrace.conditions.push(conditionTrace);
|
|
1159
|
+
if (!conditionTrace.result) {
|
|
1160
|
+
matched = false;
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
ruleTrace.matched = matched;
|
|
1165
|
+
if (matched) {
|
|
1166
|
+
const workingEffects = effectsMode === "immutable" ? cloneEffects(effects) : effects;
|
|
1167
|
+
const ctx = {
|
|
1168
|
+
facts,
|
|
1169
|
+
effects: workingEffects,
|
|
1170
|
+
trace: {
|
|
1171
|
+
note: (message) => {
|
|
1172
|
+
ruleTrace.notes.push(message);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
try {
|
|
1177
|
+
const patch = await runWithSpanAsync(
|
|
1178
|
+
telemetryAdapter,
|
|
1179
|
+
"rulit.rule",
|
|
1180
|
+
ruleSpanAttrs(rulesetName, rule),
|
|
1181
|
+
() => rule.action(ctx)
|
|
1182
|
+
);
|
|
1183
|
+
if (patch && typeof patch === "object") {
|
|
1184
|
+
mergeEffects(workingEffects, patch, mergeStrategy);
|
|
1185
|
+
}
|
|
1186
|
+
if (effectsMode === "immutable") {
|
|
1187
|
+
effects = workingEffects;
|
|
1188
|
+
}
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
ruleTrace.error = String(error);
|
|
1191
|
+
ruleTrace.notes.push(`error: ${String(error)}`);
|
|
1192
|
+
if (!rollbackOnError) {
|
|
1193
|
+
throw error;
|
|
1194
|
+
}
|
|
1195
|
+
if (effectsMode === "immutable") {
|
|
1196
|
+
effects = effects;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
fired.push(rule.id);
|
|
1200
|
+
}
|
|
1201
|
+
ruleTrace.durationMs = Date.now() - start;
|
|
1202
|
+
trace.push(ruleTrace);
|
|
1203
|
+
if (matched && activation === "first") {
|
|
1204
|
+
break;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
validateEffectsFn?.(effects);
|
|
1208
|
+
if (registryId) {
|
|
1209
|
+
registry2.recordTrace(registryId, trace, fired, facts);
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
effects,
|
|
1213
|
+
fired,
|
|
1214
|
+
trace,
|
|
1215
|
+
explain: () => explainTrace(trace, rulesetName)
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
function isPromise(value) {
|
|
1219
|
+
return Boolean(value) && typeof value.then === "function";
|
|
1220
|
+
}
|
|
1221
|
+
function runWithSpan(adapter, name, attributes, fn) {
|
|
1222
|
+
if (!adapter) {
|
|
1223
|
+
return fn();
|
|
1224
|
+
}
|
|
1225
|
+
const span = adapter.startSpan(name, attributes);
|
|
1226
|
+
try {
|
|
1227
|
+
const result = fn();
|
|
1228
|
+
span.end();
|
|
1229
|
+
return result;
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
span.recordException?.(error);
|
|
1232
|
+
span.end();
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
async function runWithSpanAsync(adapter, name, attributes, fn) {
|
|
1237
|
+
if (!adapter) {
|
|
1238
|
+
return await fn();
|
|
1239
|
+
}
|
|
1240
|
+
const span = adapter.startSpan(name, attributes);
|
|
1241
|
+
try {
|
|
1242
|
+
const result = await fn();
|
|
1243
|
+
span.end();
|
|
1244
|
+
return result;
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
span.recordException?.(error);
|
|
1247
|
+
span.end();
|
|
1248
|
+
throw error;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
function rulesetSpanAttrs(name, registryId) {
|
|
1252
|
+
return {
|
|
1253
|
+
"rulit.ruleset": name ?? registryId ?? "ruleset"
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
function ruleSpanAttrs(rulesetName, rule) {
|
|
1257
|
+
return {
|
|
1258
|
+
"rulit.ruleset": rulesetName ?? "ruleset",
|
|
1259
|
+
"rulit.rule_id": rule.id
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
function conditionSpanAttrs(rulesetName, rule, condition2) {
|
|
1263
|
+
return {
|
|
1264
|
+
"rulit.ruleset": rulesetName ?? "ruleset",
|
|
1265
|
+
"rulit.rule_id": rule.id,
|
|
1266
|
+
"rulit.condition": condition2.meta?.label ?? "condition"
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function capitalize(value) {
|
|
1270
|
+
return value.length > 0 ? value[0].toUpperCase() + value.slice(1) : value;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// src/zod.ts
|
|
1274
|
+
function zodFacts(schema) {
|
|
1275
|
+
return (facts) => {
|
|
1276
|
+
schema.parse(facts);
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
function zodEffects(schema) {
|
|
1280
|
+
return (effects) => {
|
|
1281
|
+
schema.parse(effects);
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// src/otel.ts
|
|
1286
|
+
function createOtelAdapter(tracer) {
|
|
1287
|
+
return {
|
|
1288
|
+
startSpan(name, attributes) {
|
|
1289
|
+
const span = tracer.startSpan(name, { attributes });
|
|
1290
|
+
if (attributes) {
|
|
1291
|
+
span.setAttributes?.(attributes);
|
|
1292
|
+
}
|
|
1293
|
+
return span;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// src/index.ts
|
|
1299
|
+
var Rules = {
|
|
1300
|
+
ruleset,
|
|
1301
|
+
condition,
|
|
1302
|
+
field,
|
|
1303
|
+
op,
|
|
1304
|
+
registry: registry2,
|
|
1305
|
+
zodFacts,
|
|
1306
|
+
zodEffects,
|
|
1307
|
+
otel: {
|
|
1308
|
+
createAdapter: createOtelAdapter
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
export {
|
|
1312
|
+
Rules,
|
|
1313
|
+
condition,
|
|
1314
|
+
createOtelAdapter,
|
|
1315
|
+
field,
|
|
1316
|
+
op,
|
|
1317
|
+
registry2 as registry,
|
|
1318
|
+
ruleset,
|
|
1319
|
+
zodEffects,
|
|
1320
|
+
zodFacts
|
|
1321
|
+
};
|
|
1322
|
+
//# sourceMappingURL=index.mjs.map
|