@f-o-t/rules-engine 2.0.2 → 3.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE.md +16 -4
  3. package/README.md +106 -23
  4. package/__tests__/builder.test.ts +363 -0
  5. package/__tests__/cache.test.ts +130 -0
  6. package/__tests__/config.test.ts +35 -0
  7. package/__tests__/engine.test.ts +1213 -0
  8. package/__tests__/evaluate.test.ts +339 -0
  9. package/__tests__/exports.test.ts +30 -0
  10. package/__tests__/filter-sort.test.ts +303 -0
  11. package/__tests__/integration.test.ts +419 -0
  12. package/__tests__/money-integration.test.ts +149 -0
  13. package/__tests__/validation.test.ts +862 -0
  14. package/biome.json +39 -0
  15. package/docs/MIGRATION-v3.md +118 -0
  16. package/fot.config.ts +5 -0
  17. package/package.json +31 -67
  18. package/src/analyzer/analysis.ts +401 -0
  19. package/src/builder/conditions.ts +321 -0
  20. package/src/builder/rule.ts +192 -0
  21. package/src/cache/cache.ts +135 -0
  22. package/src/cache/noop.ts +20 -0
  23. package/src/core/evaluate.ts +185 -0
  24. package/src/core/filter.ts +85 -0
  25. package/src/core/group.ts +103 -0
  26. package/src/core/sort.ts +90 -0
  27. package/src/engine/engine.ts +462 -0
  28. package/src/engine/hooks.ts +235 -0
  29. package/src/engine/state.ts +322 -0
  30. package/src/index.ts +303 -0
  31. package/src/optimizer/index-builder.ts +381 -0
  32. package/src/serialization/serializer.ts +408 -0
  33. package/src/simulation/simulator.ts +359 -0
  34. package/src/types/config.ts +184 -0
  35. package/src/types/consequence.ts +38 -0
  36. package/src/types/evaluation.ts +87 -0
  37. package/src/types/rule.ts +112 -0
  38. package/src/types/state.ts +116 -0
  39. package/src/utils/conditions.ts +108 -0
  40. package/src/utils/hash.ts +30 -0
  41. package/src/utils/id.ts +6 -0
  42. package/src/utils/time.ts +42 -0
  43. package/src/validation/conflicts.ts +440 -0
  44. package/src/validation/integrity.ts +473 -0
  45. package/src/validation/schema.ts +386 -0
  46. package/src/versioning/version-store.ts +337 -0
  47. package/tsconfig.json +29 -0
  48. package/dist/index.cjs +0 -3088
  49. package/dist/index.d.cts +0 -1173
  50. package/dist/index.d.ts +0 -1173
  51. package/dist/index.js +0 -3072
@@ -0,0 +1,419 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConditionGroup } from "@f-o-t/condition-evaluator";
3
+ import { createEvaluator } from "@f-o-t/condition-evaluator";
4
+ import {
5
+ addVersion,
6
+ all,
7
+ analyzeRuleSet,
8
+ and,
9
+ buildIndex,
10
+ checkIntegrity,
11
+ createEngine,
12
+ createVersionStore,
13
+ detectConflicts,
14
+ exportToJson,
15
+ getLatestVersion,
16
+ getRulesByField,
17
+ importFromJson,
18
+ num,
19
+ type Rule,
20
+ rollbackToVersion,
21
+ rule,
22
+ simulate,
23
+ str,
24
+ validateRule,
25
+ whatIf,
26
+ } from "../src/index";
27
+
28
+ const toRule = (input: ReturnType<ReturnType<typeof rule>["build"]>): Rule => ({
29
+ id: input.id ?? `rule-${Date.now()}-${Math.random().toString(36).slice(2)}`,
30
+ name: input.name,
31
+ description: input.description,
32
+ conditions: input.conditions,
33
+ consequences: input.consequences.map((c) => ({
34
+ type: String(c.type),
35
+ payload: c.payload,
36
+ })),
37
+ priority: input.priority ?? 0,
38
+ enabled: input.enabled ?? true,
39
+ stopOnMatch: input.stopOnMatch ?? false,
40
+ tags: input.tags ?? [],
41
+ category: input.category,
42
+ metadata: input.metadata,
43
+ createdAt: new Date(),
44
+ updatedAt: new Date(),
45
+ });
46
+
47
+ describe("Integration Tests", () => {
48
+ describe("Rule Builder and Conditions", () => {
49
+ test("creates rules with fluent builder", () => {
50
+ const discountRule = rule()
51
+ .named("Discount Rule")
52
+ .describedAs("Apply discount for large orders")
53
+ .when(
54
+ and((c) =>
55
+ c.number("amount", "gt", 100).number("itemCount", "gte", 5),
56
+ ),
57
+ )
58
+ .then("apply_discount", { percentage: 10 })
59
+ .withPriority(50)
60
+ .tagged("pricing", "discount")
61
+ .inCategory("pricing")
62
+ .build();
63
+
64
+ expect(discountRule.name).toBe("Discount Rule");
65
+ expect(discountRule.description).toBe(
66
+ "Apply discount for large orders",
67
+ );
68
+ expect(discountRule.priority).toBe(50);
69
+ expect(discountRule.tags).toContain("pricing");
70
+ expect(discountRule.category).toBe("pricing");
71
+ expect(discountRule.consequences.length).toBe(1);
72
+ });
73
+
74
+ test("creates rules with shorthand condition helpers", () => {
75
+ const conditions = all(
76
+ num("amount", "gt", 50),
77
+ str("customerType", "eq", "premium"),
78
+ );
79
+
80
+ const myRule = rule()
81
+ .named("Simple Rule")
82
+ .when(conditions)
83
+ .then("notify", { message: "Hello" })
84
+ .build();
85
+
86
+ expect(myRule.name).toBe("Simple Rule");
87
+ expect(myRule.enabled).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe("Engine Operations", () => {
92
+ test("creates engine and manages rules", () => {
93
+ const engine = createEngine({ evaluator: createEvaluator() });
94
+
95
+ const ruleInput = rule()
96
+ .named("Rule 1")
97
+ .when(all(num("amount", "gt", 100)))
98
+ .then("action", { type: "discount" })
99
+ .build();
100
+
101
+ const rule1 = engine.addRule(ruleInput);
102
+ expect(engine.getRules().length).toBe(1);
103
+
104
+ engine.disableRule(rule1.id);
105
+ expect(engine.getRule(rule1.id)?.enabled).toBe(false);
106
+
107
+ engine.enableRule(rule1.id);
108
+ expect(engine.getRule(rule1.id)?.enabled).toBe(true);
109
+
110
+ engine.removeRule(rule1.id);
111
+ expect(engine.getRules().length).toBe(0);
112
+ });
113
+
114
+ test("evaluates rules against context", async () => {
115
+ const engine = createEngine({ evaluator: createEvaluator() });
116
+
117
+ engine.addRule(
118
+ rule()
119
+ .named("High Amount")
120
+ .when(all(num("amount", "gt", 100)))
121
+ .then("flag", { level: "high" })
122
+ .withPriority(100)
123
+ .build(),
124
+ );
125
+
126
+ engine.addRule(
127
+ rule()
128
+ .named("Medium Amount")
129
+ .when(all(num("amount", "gt", 50)))
130
+ .then("flag", { level: "medium" })
131
+ .withPriority(50)
132
+ .build(),
133
+ );
134
+
135
+ const result = await engine.evaluate({ amount: 150, itemCount: 2 });
136
+
137
+ expect(result.matchedRules.length).toBe(2);
138
+ expect(result.totalRulesEvaluated).toBe(2);
139
+ });
140
+ });
141
+
142
+ describe("Simulation", () => {
143
+ test("simulates rules without side effects", () => {
144
+ const rules: Rule[] = [
145
+ toRule(
146
+ rule()
147
+ .named("Simulation Test")
148
+ .when(all(num("amount", "gt", 100)))
149
+ .then("discount", { percent: 10 })
150
+ .build(),
151
+ ),
152
+ ];
153
+
154
+ const result = simulate(rules, { data: { amount: 150 } });
155
+
156
+ expect(result.matchedRules.length).toBe(1);
157
+ expect(result.consequences.length).toBe(1);
158
+ expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
159
+ });
160
+
161
+ test("compares rules with whatIf", () => {
162
+ const originalRules: Rule[] = [
163
+ toRule(
164
+ rule()
165
+ .named("Original")
166
+ .when(all(num("amount", "gt", 100)))
167
+ .then("discount", { percent: 10 })
168
+ .build(),
169
+ ),
170
+ ];
171
+
172
+ const modifiedRules: Rule[] = [
173
+ toRule(
174
+ rule()
175
+ .named("Modified")
176
+ .when(all(num("amount", "gt", 50)))
177
+ .then("discount", { percent: 15 })
178
+ .build(),
179
+ ),
180
+ ];
181
+
182
+ const context = { data: { amount: 75 } };
183
+ const comparison = whatIf(originalRules, modifiedRules, context);
184
+
185
+ expect(comparison.original.matchedRules.length).toBe(0);
186
+ expect(comparison.modified.matchedRules.length).toBe(1);
187
+ expect(comparison.differences.newMatches.length).toBe(1);
188
+ });
189
+ });
190
+
191
+ describe("Serialization", () => {
192
+ test("exports and imports rules", () => {
193
+ const rules: Rule[] = [
194
+ toRule(
195
+ rule()
196
+ .named("Export Test")
197
+ .when(all(num("amount", "gt", 0)))
198
+ .then("action", { type: "test" })
199
+ .tagged("test")
200
+ .build(),
201
+ ),
202
+ ];
203
+
204
+ const json = exportToJson(rules);
205
+ expect(json).toContain("Export Test");
206
+ expect(json).toContain("action");
207
+
208
+ const imported = importFromJson(json);
209
+ expect(imported.success).toBe(true);
210
+ expect(imported.rules.length).toBe(1);
211
+ expect(imported.rules[0]?.name).toBe("Export Test");
212
+ });
213
+
214
+ test("generates new IDs on import", () => {
215
+ const originalRule = toRule(
216
+ rule()
217
+ .named("ID Test")
218
+ .when(all(num("amount", "gt", 0)))
219
+ .then("action", {})
220
+ .build(),
221
+ );
222
+
223
+ const json = exportToJson([originalRule]);
224
+ const imported = importFromJson(json, { generateNewIds: true });
225
+
226
+ expect(imported.rules[0]?.id).not.toBe(originalRule.id);
227
+ });
228
+ });
229
+
230
+ describe("Versioning", () => {
231
+ test("tracks rule versions", () => {
232
+ const myRule = toRule(
233
+ rule()
234
+ .named("Versioned")
235
+ .when(all(num("amount", "gt", 100)))
236
+ .then("action", { version: 1 })
237
+ .build(),
238
+ );
239
+
240
+ let store = createVersionStore();
241
+ store = addVersion(store, myRule, "create", { comment: "v1" });
242
+
243
+ const latest = getLatestVersion(store, myRule.id);
244
+ expect(latest?.version).toBe(1);
245
+ expect(latest?.comment).toBe("v1");
246
+ });
247
+
248
+ test("supports rollback", () => {
249
+ const myRule = toRule(
250
+ rule()
251
+ .named("Rollback Test")
252
+ .when(all(num("amount", "gt", 100)))
253
+ .then("action", { v: 1 })
254
+ .build(),
255
+ );
256
+
257
+ let store = createVersionStore();
258
+ store = addVersion(store, myRule, "create");
259
+
260
+ const updatedRule: Rule = {
261
+ ...myRule,
262
+ consequences: [{ type: "action", payload: { v: 2 } }],
263
+ updatedAt: new Date(),
264
+ };
265
+ store = addVersion(store, updatedRule, "update");
266
+
267
+ const { store: rolledBack } = rollbackToVersion(store, myRule.id, 1);
268
+ const latest = getLatestVersion(rolledBack, myRule.id);
269
+
270
+ expect(latest?.version).toBe(3);
271
+ });
272
+ });
273
+
274
+ describe("Indexing", () => {
275
+ test("builds and queries index", () => {
276
+ const rules: Rule[] = [
277
+ toRule(
278
+ rule()
279
+ .named("Amount Rule")
280
+ .when(all(num("amount", "gt", 100)))
281
+ .then("action", {})
282
+ .build(),
283
+ ),
284
+ toRule(
285
+ rule()
286
+ .named("Count Rule")
287
+ .when(all(num("itemCount", "gt", 5)))
288
+ .then("action", {})
289
+ .build(),
290
+ ),
291
+ ];
292
+
293
+ const index = buildIndex(rules);
294
+
295
+ expect(getRulesByField(index, "amount").length).toBe(1);
296
+ expect(getRulesByField(index, "itemCount").length).toBe(1);
297
+ expect(getRulesByField(index, "nonexistent").length).toBe(0);
298
+ });
299
+ });
300
+
301
+ describe("Analysis", () => {
302
+ test("analyzes rule set", () => {
303
+ const rules: Rule[] = [
304
+ toRule(
305
+ rule()
306
+ .named("Rule 1")
307
+ .when(all(num("amount", "gt", 100)))
308
+ .then("action", {})
309
+ .inCategory("pricing")
310
+ .build(),
311
+ ),
312
+ toRule(
313
+ rule()
314
+ .named("Rule 2")
315
+ .when(
316
+ and((c) =>
317
+ c
318
+ .number("amount", "gt", 50)
319
+ .number("itemCount", "gt", 3),
320
+ ),
321
+ )
322
+ .then("action", {})
323
+ .then("notify", { msg: "test" })
324
+ .inCategory("pricing")
325
+ .build(),
326
+ ),
327
+ ];
328
+
329
+ const analysis = analyzeRuleSet(rules);
330
+
331
+ expect(analysis.ruleCount).toBe(2);
332
+ expect(analysis.uniqueCategories).toContain("pricing");
333
+ expect(analysis.ruleComplexities.length).toBe(2);
334
+ });
335
+ });
336
+
337
+ describe("Conflict Detection", () => {
338
+ test("detects duplicate conditions", () => {
339
+ const conditions: ConditionGroup = {
340
+ id: "g1",
341
+ operator: "AND",
342
+ conditions: [
343
+ {
344
+ id: "c1",
345
+ type: "number",
346
+ field: "amount",
347
+ operator: "gt",
348
+ value: 100,
349
+ },
350
+ ],
351
+ };
352
+
353
+ const rules: Rule[] = [
354
+ {
355
+ id: "rule-1",
356
+ name: "Rule 1",
357
+ conditions,
358
+ consequences: [{ type: "a", payload: {} }],
359
+ priority: 50,
360
+ enabled: true,
361
+ stopOnMatch: false,
362
+ tags: [],
363
+ createdAt: new Date(),
364
+ updatedAt: new Date(),
365
+ },
366
+ {
367
+ id: "rule-2",
368
+ name: "Rule 2",
369
+ conditions,
370
+ consequences: [{ type: "b", payload: {} }],
371
+ priority: 50,
372
+ enabled: true,
373
+ stopOnMatch: false,
374
+ tags: [],
375
+ createdAt: new Date(),
376
+ updatedAt: new Date(),
377
+ },
378
+ ];
379
+
380
+ const conflicts = detectConflicts(rules);
381
+ expect(conflicts.some((c) => c.type === "DUPLICATE_CONDITIONS")).toBe(
382
+ true,
383
+ );
384
+ });
385
+ });
386
+
387
+ describe("Validation", () => {
388
+ test("validates rule structure", () => {
389
+ const validRule = toRule(
390
+ rule()
391
+ .named("Valid Rule")
392
+ .when(all(num("amount", "gt", 0)))
393
+ .then("action", {})
394
+ .build(),
395
+ );
396
+
397
+ const result = validateRule(validRule);
398
+ expect(result.valid).toBe(true);
399
+ });
400
+
401
+ test("checks integrity", () => {
402
+ const rules: Rule[] = [
403
+ toRule(
404
+ rule()
405
+ .named("Warning Rule")
406
+ .when(all(num("amount", "gt", 0)))
407
+ .then("action", {})
408
+ .withPriority(-10)
409
+ .build(),
410
+ ),
411
+ ];
412
+
413
+ const integrity = checkIntegrity(rules);
414
+ expect(
415
+ integrity.issues.some((i) => i.code === "NEGATIVE_PRIORITY"),
416
+ ).toBe(true);
417
+ });
418
+ });
419
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEngine } from "../src/engine/engine";
3
+ import { moneyOperators } from "@f-o-t/money/plugins/operators";
4
+ import { z } from "zod";
5
+
6
+ type TransactionContext = {
7
+ amount: {
8
+ amount: string;
9
+ currency: string;
10
+ };
11
+ accountBalance: {
12
+ amount: string;
13
+ currency: string;
14
+ };
15
+ };
16
+
17
+ const TransactionConsequences = {
18
+ approve: z.object({ approved: z.boolean() }),
19
+ require_review: z.object({ reason: z.string() }),
20
+ reject: z.object({ reason: z.string() }),
21
+ } as const;
22
+
23
+ describe("Money operators integration", () => {
24
+ test("should use money_gt operator in rules", async () => {
25
+ const engine = createEngine<TransactionContext, typeof TransactionConsequences>({
26
+ consequences: TransactionConsequences,
27
+ operators: moneyOperators,
28
+ });
29
+
30
+ engine.addRule({
31
+ name: "high-value-transaction",
32
+ conditions: {
33
+ id: "g1",
34
+ operator: "AND",
35
+ conditions: [
36
+ {
37
+ id: "c1",
38
+ type: "custom",
39
+ field: "amount",
40
+ operator: "money_gt",
41
+ value: { amount: "1000.00", currency: "BRL" },
42
+ },
43
+ ],
44
+ },
45
+ consequences: [
46
+ {
47
+ type: "require_review",
48
+ payload: { reason: "High value transaction" },
49
+ },
50
+ ],
51
+ });
52
+
53
+ const result = await engine.evaluate({
54
+ amount: { amount: "1500.00", currency: "BRL" },
55
+ accountBalance: { amount: "10000.00", currency: "BRL" },
56
+ });
57
+
58
+ expect(result.matchedRules).toHaveLength(1);
59
+ expect(result.matchedRules[0]?.name).toBe("high-value-transaction");
60
+ expect(result.consequences).toHaveLength(1);
61
+ expect(result.consequences[0]?.type).toBe("require_review");
62
+ });
63
+
64
+ test("should use money_between operator", async () => {
65
+ const engine = createEngine<TransactionContext, typeof TransactionConsequences>({
66
+ consequences: TransactionConsequences,
67
+ operators: moneyOperators,
68
+ });
69
+
70
+ engine.addRule({
71
+ name: "medium-value-transaction",
72
+ conditions: {
73
+ id: "g1",
74
+ operator: "AND",
75
+ conditions: [
76
+ {
77
+ id: "c1",
78
+ type: "custom",
79
+ field: "amount",
80
+ operator: "money_between",
81
+ value: [
82
+ { amount: "100.00", currency: "BRL" },
83
+ { amount: "1000.00", currency: "BRL" },
84
+ ],
85
+ },
86
+ ],
87
+ },
88
+ consequences: [
89
+ {
90
+ type: "approve",
91
+ payload: { approved: true },
92
+ },
93
+ ],
94
+ });
95
+
96
+ const result = await engine.evaluate({
97
+ amount: { amount: "500.00", currency: "BRL" },
98
+ accountBalance: { amount: "10000.00", currency: "BRL" },
99
+ });
100
+
101
+ expect(result.matchedRules).toHaveLength(1);
102
+ expect(result.consequences[0]?.type).toBe("approve");
103
+ });
104
+
105
+ test("should handle multiple money conditions", async () => {
106
+ const engine = createEngine<TransactionContext, typeof TransactionConsequences>({
107
+ consequences: TransactionConsequences,
108
+ operators: moneyOperators,
109
+ });
110
+
111
+ engine.addRule({
112
+ name: "insufficient-funds",
113
+ conditions: {
114
+ id: "g1",
115
+ operator: "AND",
116
+ conditions: [
117
+ {
118
+ id: "c1",
119
+ type: "custom",
120
+ field: "amount",
121
+ operator: "money_gt",
122
+ value: { amount: "0.00", currency: "BRL" },
123
+ },
124
+ {
125
+ id: "c2",
126
+ type: "custom",
127
+ field: "accountBalance",
128
+ operator: "money_lt",
129
+ value: { amount: "100.00", currency: "BRL" },
130
+ },
131
+ ],
132
+ },
133
+ consequences: [
134
+ {
135
+ type: "reject",
136
+ payload: { reason: "Insufficient funds" },
137
+ },
138
+ ],
139
+ });
140
+
141
+ const result = await engine.evaluate({
142
+ amount: { amount: "50.00", currency: "BRL" },
143
+ accountBalance: { amount: "25.00", currency: "BRL" },
144
+ });
145
+
146
+ expect(result.matchedRules).toHaveLength(1);
147
+ expect(result.consequences[0]?.type).toBe("reject");
148
+ });
149
+ });