@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.
- package/CHANGELOG.md +168 -0
- package/LICENSE.md +16 -4
- package/README.md +106 -23
- package/__tests__/builder.test.ts +363 -0
- package/__tests__/cache.test.ts +130 -0
- package/__tests__/config.test.ts +35 -0
- package/__tests__/engine.test.ts +1213 -0
- package/__tests__/evaluate.test.ts +339 -0
- package/__tests__/exports.test.ts +30 -0
- package/__tests__/filter-sort.test.ts +303 -0
- package/__tests__/integration.test.ts +419 -0
- package/__tests__/money-integration.test.ts +149 -0
- package/__tests__/validation.test.ts +862 -0
- package/biome.json +39 -0
- package/docs/MIGRATION-v3.md +118 -0
- package/fot.config.ts +5 -0
- package/package.json +31 -67
- package/src/analyzer/analysis.ts +401 -0
- package/src/builder/conditions.ts +321 -0
- package/src/builder/rule.ts +192 -0
- package/src/cache/cache.ts +135 -0
- package/src/cache/noop.ts +20 -0
- package/src/core/evaluate.ts +185 -0
- package/src/core/filter.ts +85 -0
- package/src/core/group.ts +103 -0
- package/src/core/sort.ts +90 -0
- package/src/engine/engine.ts +462 -0
- package/src/engine/hooks.ts +235 -0
- package/src/engine/state.ts +322 -0
- package/src/index.ts +303 -0
- package/src/optimizer/index-builder.ts +381 -0
- package/src/serialization/serializer.ts +408 -0
- package/src/simulation/simulator.ts +359 -0
- package/src/types/config.ts +184 -0
- package/src/types/consequence.ts +38 -0
- package/src/types/evaluation.ts +87 -0
- package/src/types/rule.ts +112 -0
- package/src/types/state.ts +116 -0
- package/src/utils/conditions.ts +108 -0
- package/src/utils/hash.ts +30 -0
- package/src/utils/id.ts +6 -0
- package/src/utils/time.ts +42 -0
- package/src/validation/conflicts.ts +440 -0
- package/src/validation/integrity.ts +473 -0
- package/src/validation/schema.ts +386 -0
- package/src/versioning/version-store.ts +337 -0
- package/tsconfig.json +29 -0
- package/dist/index.cjs +0 -3088
- package/dist/index.d.cts +0 -1173
- package/dist/index.d.ts +0 -1173
- package/dist/index.js +0 -3072
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsequenceDefinitions,
|
|
3
|
+
DefaultConsequences,
|
|
4
|
+
} from "../types/consequence";
|
|
5
|
+
import type { Rule } from "../types/rule";
|
|
6
|
+
import { collectConditionFields } from "../utils/conditions";
|
|
7
|
+
|
|
8
|
+
export type FieldIndex<
|
|
9
|
+
TContext = unknown,
|
|
10
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
11
|
+
> = Map<string, ReadonlyArray<Rule<TContext, TConsequences>>>;
|
|
12
|
+
|
|
13
|
+
export type TagIndex<
|
|
14
|
+
TContext = unknown,
|
|
15
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
16
|
+
> = Map<string, ReadonlyArray<Rule<TContext, TConsequences>>>;
|
|
17
|
+
|
|
18
|
+
export type CategoryIndex<
|
|
19
|
+
TContext = unknown,
|
|
20
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
21
|
+
> = Map<string, ReadonlyArray<Rule<TContext, TConsequences>>>;
|
|
22
|
+
|
|
23
|
+
export type PriorityIndex<
|
|
24
|
+
TContext = unknown,
|
|
25
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
26
|
+
> = Map<number, ReadonlyArray<Rule<TContext, TConsequences>>>;
|
|
27
|
+
|
|
28
|
+
export type RuleIndex<
|
|
29
|
+
TContext = unknown,
|
|
30
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
31
|
+
> = {
|
|
32
|
+
readonly byField: FieldIndex<TContext, TConsequences>;
|
|
33
|
+
readonly byTag: TagIndex<TContext, TConsequences>;
|
|
34
|
+
readonly byCategory: CategoryIndex<TContext, TConsequences>;
|
|
35
|
+
readonly byPriority: PriorityIndex<TContext, TConsequences>;
|
|
36
|
+
readonly byId: Map<string, Rule<TContext, TConsequences>>;
|
|
37
|
+
readonly sortedByPriority: ReadonlyArray<Rule<TContext, TConsequences>>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type IndexOptions = {
|
|
41
|
+
readonly indexByField?: boolean;
|
|
42
|
+
readonly indexByTag?: boolean;
|
|
43
|
+
readonly indexByCategory?: boolean;
|
|
44
|
+
readonly indexByPriority?: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DEFAULT_OPTIONS: IndexOptions = {
|
|
48
|
+
indexByField: true,
|
|
49
|
+
indexByTag: true,
|
|
50
|
+
indexByCategory: true,
|
|
51
|
+
indexByPriority: true,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const buildIndex = <
|
|
55
|
+
TContext = unknown,
|
|
56
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
57
|
+
>(
|
|
58
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
59
|
+
options: IndexOptions = {},
|
|
60
|
+
): RuleIndex<TContext, TConsequences> => {
|
|
61
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
62
|
+
|
|
63
|
+
const byId = new Map<string, Rule<TContext, TConsequences>>();
|
|
64
|
+
const byField = new Map<string, Rule<TContext, TConsequences>[]>();
|
|
65
|
+
const byTag = new Map<string, Rule<TContext, TConsequences>[]>();
|
|
66
|
+
const byCategory = new Map<string, Rule<TContext, TConsequences>[]>();
|
|
67
|
+
const byPriority = new Map<number, Rule<TContext, TConsequences>[]>();
|
|
68
|
+
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
byId.set(rule.id, rule);
|
|
71
|
+
|
|
72
|
+
if (opts.indexByField) {
|
|
73
|
+
const fields = collectConditionFields(rule.conditions);
|
|
74
|
+
for (const field of fields) {
|
|
75
|
+
const existing = byField.get(field) ?? [];
|
|
76
|
+
existing.push(rule);
|
|
77
|
+
byField.set(field, existing);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (opts.indexByTag) {
|
|
82
|
+
for (const tag of rule.tags) {
|
|
83
|
+
const existing = byTag.get(tag) ?? [];
|
|
84
|
+
existing.push(rule);
|
|
85
|
+
byTag.set(tag, existing);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (opts.indexByCategory && rule.category) {
|
|
90
|
+
const existing = byCategory.get(rule.category) ?? [];
|
|
91
|
+
existing.push(rule);
|
|
92
|
+
byCategory.set(rule.category, existing);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (opts.indexByPriority) {
|
|
96
|
+
const existing = byPriority.get(rule.priority) ?? [];
|
|
97
|
+
existing.push(rule);
|
|
98
|
+
byPriority.set(rule.priority, existing);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sortedByPriority = [...rules].sort((a, b) => b.priority - a.priority);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
byField: byField as FieldIndex<TContext, TConsequences>,
|
|
106
|
+
byTag: byTag as TagIndex<TContext, TConsequences>,
|
|
107
|
+
byCategory: byCategory as CategoryIndex<TContext, TConsequences>,
|
|
108
|
+
byPriority: byPriority as PriorityIndex<TContext, TConsequences>,
|
|
109
|
+
byId,
|
|
110
|
+
sortedByPriority,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const getRulesByField = <
|
|
115
|
+
TContext = unknown,
|
|
116
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
117
|
+
>(
|
|
118
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
119
|
+
field: string,
|
|
120
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
121
|
+
return index.byField.get(field) ?? [];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const getRulesByFields = <
|
|
125
|
+
TContext = unknown,
|
|
126
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
127
|
+
>(
|
|
128
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
129
|
+
fields: ReadonlyArray<string>,
|
|
130
|
+
mode: "any" | "all" = "any",
|
|
131
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
132
|
+
if (fields.length === 0) return [];
|
|
133
|
+
|
|
134
|
+
const ruleSets = fields.map((f) => new Set(getRulesByField(index, f)));
|
|
135
|
+
|
|
136
|
+
if (mode === "any") {
|
|
137
|
+
const combined = new Set<Rule<TContext, TConsequences>>();
|
|
138
|
+
for (const ruleSet of ruleSets) {
|
|
139
|
+
for (const rule of ruleSet) {
|
|
140
|
+
combined.add(rule);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [...combined];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const [first, ...rest] = ruleSets;
|
|
147
|
+
if (!first) return [];
|
|
148
|
+
|
|
149
|
+
return [...first].filter((rule) => rest.every((set) => set.has(rule)));
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const getRulesByTag = <
|
|
153
|
+
TContext = unknown,
|
|
154
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
155
|
+
>(
|
|
156
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
157
|
+
tag: string,
|
|
158
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
159
|
+
return index.byTag.get(tag) ?? [];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const getRulesByTags = <
|
|
163
|
+
TContext = unknown,
|
|
164
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
165
|
+
>(
|
|
166
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
167
|
+
tags: ReadonlyArray<string>,
|
|
168
|
+
mode: "any" | "all" = "any",
|
|
169
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
170
|
+
if (tags.length === 0) return [];
|
|
171
|
+
|
|
172
|
+
const ruleSets = tags.map((t) => new Set(getRulesByTag(index, t)));
|
|
173
|
+
|
|
174
|
+
if (mode === "any") {
|
|
175
|
+
const combined = new Set<Rule<TContext, TConsequences>>();
|
|
176
|
+
for (const ruleSet of ruleSets) {
|
|
177
|
+
for (const rule of ruleSet) {
|
|
178
|
+
combined.add(rule);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [...combined];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const [first, ...rest] = ruleSets;
|
|
185
|
+
if (!first) return [];
|
|
186
|
+
|
|
187
|
+
return [...first].filter((rule) => rest.every((set) => set.has(rule)));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const getRulesByCategory = <
|
|
191
|
+
TContext = unknown,
|
|
192
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
193
|
+
>(
|
|
194
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
195
|
+
category: string,
|
|
196
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
197
|
+
return index.byCategory.get(category) ?? [];
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const getRulesByPriority = <
|
|
201
|
+
TContext = unknown,
|
|
202
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
203
|
+
>(
|
|
204
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
205
|
+
priority: number,
|
|
206
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
207
|
+
return index.byPriority.get(priority) ?? [];
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const getRulesByPriorityRange = <
|
|
211
|
+
TContext = unknown,
|
|
212
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
213
|
+
>(
|
|
214
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
215
|
+
minPriority: number,
|
|
216
|
+
maxPriority: number,
|
|
217
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
218
|
+
return index.sortedByPriority.filter(
|
|
219
|
+
(rule) => rule.priority >= minPriority && rule.priority <= maxPriority,
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const getRuleById = <
|
|
224
|
+
TContext = unknown,
|
|
225
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
226
|
+
>(
|
|
227
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
228
|
+
id: string,
|
|
229
|
+
): Rule<TContext, TConsequences> | undefined => {
|
|
230
|
+
return index.byId.get(id);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const filterRulesForContext = <
|
|
234
|
+
TContext = unknown,
|
|
235
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
236
|
+
>(
|
|
237
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
238
|
+
contextFields: ReadonlyArray<string>,
|
|
239
|
+
): ReadonlyArray<Rule<TContext, TConsequences>> => {
|
|
240
|
+
const relevantRules = new Set<Rule<TContext, TConsequences>>();
|
|
241
|
+
|
|
242
|
+
for (const field of contextFields) {
|
|
243
|
+
const rules = index.byField.get(field);
|
|
244
|
+
if (rules) {
|
|
245
|
+
for (const rule of rules) {
|
|
246
|
+
relevantRules.add(rule);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return [...relevantRules].sort((a, b) => b.priority - a.priority);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export type OptimizationSuggestion = {
|
|
255
|
+
readonly type: "INDEX" | "CACHE" | "MERGE" | "SIMPLIFY" | "REORDER";
|
|
256
|
+
readonly severity: "high" | "medium" | "low";
|
|
257
|
+
readonly message: string;
|
|
258
|
+
readonly ruleIds?: ReadonlyArray<string>;
|
|
259
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const analyzeOptimizations = <
|
|
263
|
+
TContext = unknown,
|
|
264
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
265
|
+
>(
|
|
266
|
+
rules: ReadonlyArray<Rule<TContext, TConsequences>>,
|
|
267
|
+
): ReadonlyArray<OptimizationSuggestion> => {
|
|
268
|
+
const suggestions: OptimizationSuggestion[] = [];
|
|
269
|
+
|
|
270
|
+
const fieldUsage = new Map<string, number>();
|
|
271
|
+
for (const rule of rules) {
|
|
272
|
+
const fields = collectConditionFields(rule.conditions);
|
|
273
|
+
for (const field of fields) {
|
|
274
|
+
fieldUsage.set(field, (fieldUsage.get(field) ?? 0) + 1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const frequentFields = [...fieldUsage.entries()]
|
|
279
|
+
.filter(([, count]) => count > rules.length * 0.5)
|
|
280
|
+
.map(([field]) => field);
|
|
281
|
+
|
|
282
|
+
if (frequentFields.length > 0) {
|
|
283
|
+
suggestions.push({
|
|
284
|
+
type: "INDEX",
|
|
285
|
+
severity: "medium",
|
|
286
|
+
message: `Consider creating indexes for frequently used fields: ${frequentFields.join(", ")}`,
|
|
287
|
+
details: {
|
|
288
|
+
fields: frequentFields,
|
|
289
|
+
usagePercentage: frequentFields.map((f) => ({
|
|
290
|
+
field: f,
|
|
291
|
+
percentage: ((fieldUsage.get(f) ?? 0) / rules.length) * 100,
|
|
292
|
+
})),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const priorityGroups = new Map<number, Rule<TContext, TConsequences>[]>();
|
|
298
|
+
for (const rule of rules) {
|
|
299
|
+
const existing = priorityGroups.get(rule.priority) ?? [];
|
|
300
|
+
existing.push(rule);
|
|
301
|
+
priorityGroups.set(rule.priority, existing);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const largePriorityGroups = [...priorityGroups.entries()].filter(
|
|
305
|
+
([, group]) => group.length > 5,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (largePriorityGroups.length > 0) {
|
|
309
|
+
for (const [priority, group] of largePriorityGroups) {
|
|
310
|
+
suggestions.push({
|
|
311
|
+
type: "REORDER",
|
|
312
|
+
severity: "low",
|
|
313
|
+
message: `${group.length} rules share priority ${priority}. Consider differentiating priorities for more predictable execution order.`,
|
|
314
|
+
ruleIds: group.map((r) => r.id),
|
|
315
|
+
details: { priority, count: group.length },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const disabledRules = rules.filter((r) => !r.enabled);
|
|
321
|
+
if (disabledRules.length > rules.length * 0.3) {
|
|
322
|
+
suggestions.push({
|
|
323
|
+
type: "SIMPLIFY",
|
|
324
|
+
severity: "low",
|
|
325
|
+
message: `${disabledRules.length} rules (${((disabledRules.length / rules.length) * 100).toFixed(1)}%) are disabled. Consider removing unused rules.`,
|
|
326
|
+
ruleIds: disabledRules.map((r) => r.id),
|
|
327
|
+
details: {
|
|
328
|
+
disabledCount: disabledRules.length,
|
|
329
|
+
totalCount: rules.length,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (rules.length > 100) {
|
|
335
|
+
suggestions.push({
|
|
336
|
+
type: "CACHE",
|
|
337
|
+
severity: "high",
|
|
338
|
+
message: `Rule set contains ${rules.length} rules. Enable caching for better performance.`,
|
|
339
|
+
details: { ruleCount: rules.length },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return suggestions;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
export const getIndexStats = <
|
|
347
|
+
TContext = unknown,
|
|
348
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
349
|
+
>(
|
|
350
|
+
index: RuleIndex<TContext, TConsequences>,
|
|
351
|
+
): {
|
|
352
|
+
totalRules: number;
|
|
353
|
+
uniqueFields: number;
|
|
354
|
+
uniqueTags: number;
|
|
355
|
+
uniqueCategories: number;
|
|
356
|
+
uniquePriorities: number;
|
|
357
|
+
averageRulesPerField: number;
|
|
358
|
+
averageRulesPerTag: number;
|
|
359
|
+
} => {
|
|
360
|
+
const totalRules = index.byId.size;
|
|
361
|
+
|
|
362
|
+
const fieldRuleCounts = [...index.byField.values()].map((r) => r.length);
|
|
363
|
+
const tagRuleCounts = [...index.byTag.values()].map((r) => r.length);
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
totalRules,
|
|
367
|
+
uniqueFields: index.byField.size,
|
|
368
|
+
uniqueTags: index.byTag.size,
|
|
369
|
+
uniqueCategories: index.byCategory.size,
|
|
370
|
+
uniquePriorities: index.byPriority.size,
|
|
371
|
+
averageRulesPerField:
|
|
372
|
+
fieldRuleCounts.length > 0
|
|
373
|
+
? fieldRuleCounts.reduce((a, b) => a + b, 0) /
|
|
374
|
+
fieldRuleCounts.length
|
|
375
|
+
: 0,
|
|
376
|
+
averageRulesPerTag:
|
|
377
|
+
tagRuleCounts.length > 0
|
|
378
|
+
? tagRuleCounts.reduce((a, b) => a + b, 0) / tagRuleCounts.length
|
|
379
|
+
: 0,
|
|
380
|
+
};
|
|
381
|
+
};
|