@f-o-t/rules-engine 2.0.1 → 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 -1317
  50. package/dist/index.d.ts +0 -1317
  51. package/dist/index.js +0 -3072
@@ -0,0 +1,112 @@
1
+ import type { ConditionGroup } from "@f-o-t/condition-evaluator";
2
+ import { z } from "zod";
3
+ import type {
4
+ Consequence,
5
+ ConsequenceDefinitions,
6
+ DefaultConsequences,
7
+ } from "./consequence";
8
+
9
+ export type Rule<
10
+ _TContext = unknown,
11
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
12
+ > = {
13
+ readonly id: string;
14
+ readonly name: string;
15
+ readonly description?: string;
16
+ readonly conditions: ConditionGroup;
17
+ readonly consequences: ReadonlyArray<Consequence<TConsequences>>;
18
+ readonly priority: number;
19
+ readonly enabled: boolean;
20
+ readonly stopOnMatch: boolean;
21
+ readonly tags: ReadonlyArray<string>;
22
+ readonly category?: string;
23
+ readonly metadata?: Readonly<Record<string, unknown>>;
24
+ readonly createdAt: Date;
25
+ readonly updatedAt: Date;
26
+ };
27
+
28
+ export type RuleInput<
29
+ _TContext = unknown,
30
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
31
+ > = {
32
+ id?: string;
33
+ name: string;
34
+ description?: string;
35
+ conditions: ConditionGroup;
36
+ consequences: Array<{
37
+ type: keyof TConsequences;
38
+ payload: z.infer<TConsequences[keyof TConsequences]>;
39
+ }>;
40
+ priority?: number;
41
+ enabled?: boolean;
42
+ stopOnMatch?: boolean;
43
+ tags?: string[];
44
+ category?: string;
45
+ metadata?: Record<string, unknown>;
46
+ };
47
+
48
+ export type RuleSet = {
49
+ readonly id: string;
50
+ readonly name: string;
51
+ readonly description?: string;
52
+ readonly ruleIds: ReadonlyArray<string>;
53
+ readonly enabled: boolean;
54
+ readonly metadata?: Readonly<Record<string, unknown>>;
55
+ };
56
+
57
+ export type RuleSetInput = {
58
+ id?: string;
59
+ name: string;
60
+ description?: string;
61
+ ruleIds: string[];
62
+ enabled?: boolean;
63
+ metadata?: Record<string, unknown>;
64
+ };
65
+
66
+ export type RuleFilters = {
67
+ readonly enabled?: boolean;
68
+ readonly tags?: ReadonlyArray<string>;
69
+ readonly category?: string;
70
+ readonly ruleSetId?: string;
71
+ readonly ids?: ReadonlyArray<string>;
72
+ };
73
+
74
+ export const RuleSchema = z.object({
75
+ id: z.string().min(1),
76
+ name: z.string().min(1),
77
+ description: z.string().optional(),
78
+ conditions: z.custom<ConditionGroup>((val) => {
79
+ return (
80
+ typeof val === "object" &&
81
+ val !== null &&
82
+ "id" in val &&
83
+ "operator" in val
84
+ );
85
+ }, "Invalid condition group"),
86
+ consequences: z.array(
87
+ z.object({
88
+ type: z.string(),
89
+ payload: z.unknown(),
90
+ }),
91
+ ),
92
+ priority: z.number().int().default(0),
93
+ enabled: z.boolean().default(true),
94
+ stopOnMatch: z.boolean().default(false),
95
+ tags: z.array(z.string()).default([]),
96
+ category: z.string().optional(),
97
+ metadata: z.record(z.string(), z.unknown()).optional(),
98
+ createdAt: z.date().default(() => new Date()),
99
+ updatedAt: z.date().default(() => new Date()),
100
+ });
101
+
102
+ export const RuleSetSchema = z.object({
103
+ id: z.string().min(1),
104
+ name: z.string().min(1),
105
+ description: z.string().optional(),
106
+ ruleIds: z.array(z.string()),
107
+ enabled: z.boolean().default(true),
108
+ metadata: z.record(z.string(), z.unknown()).optional(),
109
+ });
110
+
111
+ export type RuleSchemaType = z.infer<typeof RuleSchema>;
112
+ export type RuleSetSchemaType = z.infer<typeof RuleSetSchema>;
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ import type {
3
+ ConsequenceDefinitions,
4
+ DefaultConsequences,
5
+ } from "./consequence";
6
+ import type { Rule, RuleSet } from "./rule";
7
+
8
+ // ============================================================================
9
+ // Zod Schemas - Define schemas first, then infer types
10
+ // ============================================================================
11
+
12
+ export const RuleStatsSchema = z.object({
13
+ evaluations: z.number().int().nonnegative(),
14
+ matches: z.number().int().nonnegative(),
15
+ errors: z.number().int().nonnegative(),
16
+ totalTimeMs: z.number().nonnegative(),
17
+ avgTimeMs: z.number().nonnegative(),
18
+ lastEvaluated: z.date().optional(),
19
+ });
20
+ export type RuleStats = z.infer<typeof RuleStatsSchema>;
21
+
22
+ export const CacheStatsSchema = z.object({
23
+ size: z.number().int().nonnegative(),
24
+ maxSize: z.number().int().positive(),
25
+ hits: z.number().int().nonnegative(),
26
+ misses: z.number().int().nonnegative(),
27
+ hitRate: z.number().min(0).max(1),
28
+ evictions: z.number().int().nonnegative(),
29
+ });
30
+ export type CacheStats = z.infer<typeof CacheStatsSchema>;
31
+
32
+ export const EngineStatsSchema = z.object({
33
+ totalRules: z.number().int().nonnegative(),
34
+ enabledRules: z.number().int().nonnegative(),
35
+ disabledRules: z.number().int().nonnegative(),
36
+ totalRuleSets: z.number().int().nonnegative(),
37
+ totalEvaluations: z.number().int().nonnegative(),
38
+ totalMatches: z.number().int().nonnegative(),
39
+ totalErrors: z.number().int().nonnegative(),
40
+ avgEvaluationTimeMs: z.number().nonnegative(),
41
+ cacheHits: z.number().int().nonnegative(),
42
+ cacheMisses: z.number().int().nonnegative(),
43
+ cacheHitRate: z.number().min(0).max(1),
44
+ });
45
+ export type EngineStats = z.infer<typeof EngineStatsSchema>;
46
+
47
+ // ============================================================================
48
+ // Types that cannot be Zod schemas (contain Maps or complex generics)
49
+ // ============================================================================
50
+
51
+ export type EngineState<
52
+ TContext = unknown,
53
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
54
+ > = {
55
+ readonly rules: ReadonlyMap<string, Rule<TContext, TConsequences>>;
56
+ readonly ruleSets: ReadonlyMap<string, RuleSet>;
57
+ readonly ruleOrder: ReadonlyArray<string>;
58
+ };
59
+
60
+ // Internal mutable version - not exported from index.ts
61
+ export type MutableEngineState<
62
+ TContext = unknown,
63
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
64
+ > = {
65
+ rules: Map<string, Rule<TContext, TConsequences>>;
66
+ ruleSets: Map<string, RuleSet>;
67
+ ruleOrder: string[];
68
+ };
69
+
70
+ // Internal mutable version - not exported from index.ts
71
+ export type MutableRuleStats = {
72
+ evaluations: number;
73
+ matches: number;
74
+ errors: number;
75
+ totalTimeMs: number;
76
+ avgTimeMs: number;
77
+ lastEvaluated?: Date;
78
+ };
79
+
80
+ export type OptimizerState = {
81
+ readonly ruleStats: ReadonlyMap<string, RuleStats>;
82
+ readonly lastOptimized?: Date;
83
+ };
84
+
85
+ // Internal mutable version - not exported from index.ts
86
+ export type MutableOptimizerState = {
87
+ ruleStats: Map<string, MutableRuleStats>;
88
+ lastOptimized?: Date;
89
+ };
90
+
91
+ // ============================================================================
92
+ // Factory functions
93
+ // ============================================================================
94
+
95
+ export const createInitialState = <
96
+ TContext = unknown,
97
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
98
+ >(): MutableEngineState<TContext, TConsequences> => ({
99
+ rules: new Map(),
100
+ ruleSets: new Map(),
101
+ ruleOrder: [],
102
+ });
103
+
104
+ export const createInitialOptimizerState = (): MutableOptimizerState => ({
105
+ ruleStats: new Map(),
106
+ lastOptimized: undefined,
107
+ });
108
+
109
+ export const createInitialRuleStats = (): MutableRuleStats => ({
110
+ evaluations: 0,
111
+ matches: 0,
112
+ errors: 0,
113
+ totalTimeMs: 0,
114
+ avgTimeMs: 0,
115
+ lastEvaluated: undefined,
116
+ });
@@ -0,0 +1,108 @@
1
+ import {
2
+ type Condition,
3
+ type ConditionGroup,
4
+ isConditionGroup,
5
+ } from "@f-o-t/condition-evaluator";
6
+
7
+ /**
8
+ * Collects all unique field names from a condition tree.
9
+ * Traverses nested condition groups recursively.
10
+ */
11
+ export const collectConditionFields = (
12
+ condition: ConditionGroup,
13
+ ): Set<string> => {
14
+ const fields = new Set<string>();
15
+
16
+ const traverse = (c: Condition | ConditionGroup) => {
17
+ if (isConditionGroup(c)) {
18
+ for (const child of c.conditions) {
19
+ traverse(child as Condition | ConditionGroup);
20
+ }
21
+ } else {
22
+ fields.add(c.field);
23
+ }
24
+ };
25
+
26
+ traverse(condition);
27
+ return fields;
28
+ };
29
+
30
+ /**
31
+ * Collects all unique operators from a condition tree.
32
+ * Returns a map of operator to its type (e.g., "equals" -> "string").
33
+ */
34
+ export const collectConditionOperators = (
35
+ condition: ConditionGroup,
36
+ ): Map<string, Set<string>> => {
37
+ const operators = new Map<string, Set<string>>();
38
+
39
+ const traverse = (c: Condition | ConditionGroup) => {
40
+ if (isConditionGroup(c)) {
41
+ for (const child of c.conditions) {
42
+ traverse(child as Condition | ConditionGroup);
43
+ }
44
+ } else {
45
+ const existing = operators.get(c.operator) ?? new Set<string>();
46
+ existing.add(c.type);
47
+ operators.set(c.operator, existing);
48
+ }
49
+ };
50
+
51
+ traverse(condition);
52
+ return operators;
53
+ };
54
+
55
+ /**
56
+ * Counts the total number of leaf conditions in a condition tree.
57
+ */
58
+ export const countConditions = (
59
+ condition: Condition | ConditionGroup,
60
+ ): number => {
61
+ if (isConditionGroup(condition)) {
62
+ return condition.conditions.reduce(
63
+ (sum: number, c: Condition | ConditionGroup) => sum + countConditions(c as Condition | ConditionGroup),
64
+ 0,
65
+ );
66
+ }
67
+ return 1;
68
+ };
69
+
70
+ /**
71
+ * Calculates the maximum depth of a condition tree.
72
+ */
73
+ export const calculateMaxDepth = (
74
+ condition: Condition | ConditionGroup,
75
+ currentDepth = 1,
76
+ ): number => {
77
+ if (isConditionGroup(condition)) {
78
+ if (condition.conditions.length === 0) return currentDepth;
79
+ return Math.max(
80
+ ...condition.conditions.map((c: Condition | ConditionGroup) =>
81
+ calculateMaxDepth(
82
+ c as Condition | ConditionGroup,
83
+ currentDepth + 1,
84
+ ),
85
+ ),
86
+ );
87
+ }
88
+ return currentDepth;
89
+ };
90
+
91
+ /**
92
+ * Counts the number of condition groups in a condition tree.
93
+ */
94
+ export const countConditionGroups = (
95
+ condition: Condition | ConditionGroup,
96
+ ): number => {
97
+ if (isConditionGroup(condition)) {
98
+ return (
99
+ 1 +
100
+ condition.conditions.reduce(
101
+ (sum: number, c: Condition | ConditionGroup) =>
102
+ sum + countConditionGroups(c as Condition | ConditionGroup),
103
+ 0,
104
+ )
105
+ );
106
+ }
107
+ return 0;
108
+ };
@@ -0,0 +1,30 @@
1
+ export const hashContext = (context: unknown): string => {
2
+ const str = JSON.stringify(context, (_, value) => {
3
+ if (value instanceof Date) {
4
+ return value.toISOString();
5
+ }
6
+ if (value instanceof Map) {
7
+ return Object.fromEntries(value);
8
+ }
9
+ if (value instanceof Set) {
10
+ return Array.from(value);
11
+ }
12
+ return value;
13
+ });
14
+
15
+ if (typeof Bun !== "undefined" && Bun.hash) {
16
+ return Bun.hash(str).toString(16);
17
+ }
18
+
19
+ let hash = 0;
20
+ for (let i = 0; i < str.length; i++) {
21
+ const char = str.charCodeAt(i);
22
+ hash = (hash << 5) - hash + char;
23
+ hash = hash & hash;
24
+ }
25
+ return Math.abs(hash).toString(16);
26
+ };
27
+
28
+ export const hashRules = (ruleIds: ReadonlyArray<string>): string => {
29
+ return hashContext(ruleIds.slice().sort());
30
+ };
@@ -0,0 +1,6 @@
1
+ export const generateId = (): string => {
2
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
3
+ return crypto.randomUUID();
4
+ }
5
+ return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`;
6
+ };
@@ -0,0 +1,42 @@
1
+ export type TimingResult<T> = {
2
+ readonly result: T;
3
+ readonly durationMs: number;
4
+ };
5
+
6
+ export const measureTime = <T>(fn: () => T): TimingResult<T> => {
7
+ const start = performance.now();
8
+ const result = fn();
9
+ const durationMs = performance.now() - start;
10
+ return { result, durationMs };
11
+ };
12
+
13
+ export const measureTimeAsync = async <T>(
14
+ fn: () => Promise<T>,
15
+ ): Promise<TimingResult<T>> => {
16
+ const start = performance.now();
17
+ const result = await fn();
18
+ const durationMs = performance.now() - start;
19
+ return { result, durationMs };
20
+ };
21
+
22
+ export const withTimeout = <T>(
23
+ promise: Promise<T>,
24
+ timeoutMs: number,
25
+ errorMessage = "Operation timed out",
26
+ ): Promise<T> => {
27
+ return new Promise((resolve, reject) => {
28
+ const timer = setTimeout(() => {
29
+ reject(new Error(errorMessage));
30
+ }, timeoutMs);
31
+
32
+ promise
33
+ .then((result) => {
34
+ clearTimeout(timer);
35
+ resolve(result);
36
+ })
37
+ .catch((error) => {
38
+ clearTimeout(timer);
39
+ reject(error);
40
+ });
41
+ });
42
+ };