@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,408 @@
1
+ import type { ConditionGroup } from "@f-o-t/condition-evaluator";
2
+ import type {
3
+ ConsequenceDefinitions,
4
+ DefaultConsequences,
5
+ } from "../types/consequence";
6
+ import type { Rule, RuleSet } from "../types/rule";
7
+ import { generateId } from "../utils/id";
8
+
9
+ export type SerializedRule = {
10
+ readonly id: string;
11
+ readonly name: string;
12
+ readonly description?: string;
13
+ readonly conditions: ConditionGroup;
14
+ readonly consequences: ReadonlyArray<{
15
+ type: string;
16
+ payload: unknown;
17
+ }>;
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: string;
25
+ readonly updatedAt: string;
26
+ };
27
+
28
+ export type SerializedRuleSet = {
29
+ readonly id: string;
30
+ readonly name: string;
31
+ readonly description?: string;
32
+ readonly ruleIds: ReadonlyArray<string>;
33
+ readonly enabled: boolean;
34
+ readonly metadata?: Readonly<Record<string, unknown>>;
35
+ };
36
+
37
+ export type ExportFormat = {
38
+ readonly version: string;
39
+ readonly exportedAt: string;
40
+ readonly rules: ReadonlyArray<SerializedRule>;
41
+ readonly ruleSets?: ReadonlyArray<SerializedRuleSet>;
42
+ readonly metadata?: Readonly<Record<string, unknown>>;
43
+ };
44
+
45
+ export type ImportOptions = {
46
+ readonly generateNewIds?: boolean;
47
+ readonly idPrefix?: string;
48
+ readonly preserveDates?: boolean;
49
+ readonly validateSchema?: boolean;
50
+ };
51
+
52
+ export type OrphanedReference = {
53
+ readonly ruleSetId: string;
54
+ readonly ruleSetName: string;
55
+ readonly missingRuleIds: ReadonlyArray<string>;
56
+ };
57
+
58
+ export type ImportResult<
59
+ TContext = unknown,
60
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
61
+ > = {
62
+ readonly success: boolean;
63
+ readonly rules: ReadonlyArray<Rule<TContext, TConsequences>>;
64
+ readonly ruleSets: ReadonlyArray<RuleSet>;
65
+ readonly errors: ReadonlyArray<{
66
+ index: number;
67
+ type: "rule" | "ruleSet";
68
+ message: string;
69
+ }>;
70
+ readonly idMapping: ReadonlyMap<string, string>;
71
+ readonly orphanedReferences: ReadonlyArray<OrphanedReference>;
72
+ };
73
+
74
+ const EXPORT_VERSION = "1.0.0";
75
+
76
+ export const serializeRule = <
77
+ TContext = unknown,
78
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
79
+ >(
80
+ rule: Rule<TContext, TConsequences>,
81
+ ): SerializedRule => ({
82
+ id: rule.id,
83
+ name: rule.name,
84
+ description: rule.description,
85
+ conditions: rule.conditions,
86
+ consequences: rule.consequences.map((c) => ({
87
+ type: c.type as string,
88
+ payload: c.payload,
89
+ })),
90
+ priority: rule.priority,
91
+ enabled: rule.enabled,
92
+ stopOnMatch: rule.stopOnMatch,
93
+ tags: rule.tags,
94
+ category: rule.category,
95
+ metadata: rule.metadata,
96
+ createdAt: rule.createdAt.toISOString(),
97
+ updatedAt: rule.updatedAt.toISOString(),
98
+ });
99
+
100
+ export const deserializeRule = <
101
+ TContext = unknown,
102
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
103
+ >(
104
+ serialized: SerializedRule,
105
+ options: ImportOptions = {},
106
+ ): Rule<TContext, TConsequences> => {
107
+ const id = options.generateNewIds
108
+ ? `${options.idPrefix ?? ""}${generateId()}`
109
+ : serialized.id;
110
+
111
+ const now = new Date();
112
+
113
+ return {
114
+ id,
115
+ name: serialized.name,
116
+ description: serialized.description,
117
+ conditions: serialized.conditions,
118
+ consequences: serialized.consequences as Rule<
119
+ TContext,
120
+ TConsequences
121
+ >["consequences"],
122
+ priority: serialized.priority,
123
+ enabled: serialized.enabled,
124
+ stopOnMatch: serialized.stopOnMatch,
125
+ tags: serialized.tags,
126
+ category: serialized.category,
127
+ metadata: serialized.metadata,
128
+ createdAt: options.preserveDates ? new Date(serialized.createdAt) : now,
129
+ updatedAt: options.preserveDates ? new Date(serialized.updatedAt) : now,
130
+ };
131
+ };
132
+
133
+ export const serializeRuleSet = (ruleSet: RuleSet): SerializedRuleSet => ({
134
+ id: ruleSet.id,
135
+ name: ruleSet.name,
136
+ description: ruleSet.description,
137
+ ruleIds: ruleSet.ruleIds,
138
+ enabled: ruleSet.enabled,
139
+ metadata: ruleSet.metadata,
140
+ });
141
+
142
+ export const deserializeRuleSet = (
143
+ serialized: SerializedRuleSet,
144
+ idMapping: Map<string, string>,
145
+ options: ImportOptions = {},
146
+ ): RuleSet => {
147
+ const id = options.generateNewIds
148
+ ? `${options.idPrefix ?? ""}${generateId()}`
149
+ : serialized.id;
150
+
151
+ const ruleIds = serialized.ruleIds.map(
152
+ (oldId) => idMapping.get(oldId) ?? oldId,
153
+ );
154
+
155
+ return {
156
+ id,
157
+ name: serialized.name,
158
+ description: serialized.description,
159
+ ruleIds,
160
+ enabled: serialized.enabled,
161
+ metadata: serialized.metadata,
162
+ };
163
+ };
164
+
165
+ export const exportRules = <
166
+ TContext = unknown,
167
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
168
+ >(
169
+ rules: ReadonlyArray<Rule<TContext, TConsequences>>,
170
+ ruleSets?: ReadonlyArray<RuleSet>,
171
+ metadata?: Record<string, unknown>,
172
+ ): ExportFormat => ({
173
+ version: EXPORT_VERSION,
174
+ exportedAt: new Date().toISOString(),
175
+ rules: rules.map(serializeRule),
176
+ ruleSets: ruleSets?.map(serializeRuleSet),
177
+ metadata,
178
+ });
179
+
180
+ export const exportToJson = <
181
+ TContext = unknown,
182
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
183
+ >(
184
+ rules: ReadonlyArray<Rule<TContext, TConsequences>>,
185
+ ruleSets?: ReadonlyArray<RuleSet>,
186
+ metadata?: Record<string, unknown>,
187
+ ): string => {
188
+ const exportData = exportRules(rules, ruleSets, metadata);
189
+ return JSON.stringify(exportData, null, 2);
190
+ };
191
+
192
+ export const importRules = <
193
+ TContext = unknown,
194
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
195
+ >(
196
+ data: ExportFormat,
197
+ options: ImportOptions = {},
198
+ ): ImportResult<TContext, TConsequences> => {
199
+ const rules: Rule<TContext, TConsequences>[] = [];
200
+ const ruleSets: RuleSet[] = [];
201
+ const errors: Array<{
202
+ index: number;
203
+ type: "rule" | "ruleSet";
204
+ message: string;
205
+ }> = [];
206
+ const idMapping = new Map<string, string>();
207
+
208
+ for (let i = 0; i < data.rules.length; i++) {
209
+ try {
210
+ const serialized = data.rules[i];
211
+ if (!serialized) continue;
212
+ const rule = deserializeRule<TContext, TConsequences>(
213
+ serialized,
214
+ options,
215
+ );
216
+ idMapping.set(serialized.id, rule.id);
217
+ rules.push(rule);
218
+ } catch (error) {
219
+ errors.push({
220
+ index: i,
221
+ type: "rule",
222
+ message: error instanceof Error ? error.message : String(error),
223
+ });
224
+ }
225
+ }
226
+
227
+ if (data.ruleSets) {
228
+ for (let i = 0; i < data.ruleSets.length; i++) {
229
+ try {
230
+ const serialized = data.ruleSets[i];
231
+ if (!serialized) continue;
232
+ const ruleSet = deserializeRuleSet(serialized, idMapping, options);
233
+ ruleSets.push(ruleSet);
234
+ } catch (error) {
235
+ errors.push({
236
+ index: i,
237
+ type: "ruleSet",
238
+ message: error instanceof Error ? error.message : String(error),
239
+ });
240
+ }
241
+ }
242
+ }
243
+
244
+ // Detect orphaned references (ruleSets referencing non-existent rules)
245
+ const importedRuleIds = new Set(rules.map((r) => r.id));
246
+ const orphanedReferences: OrphanedReference[] = [];
247
+
248
+ for (const ruleSet of ruleSets) {
249
+ const missingRuleIds = ruleSet.ruleIds.filter(
250
+ (id) => !importedRuleIds.has(id),
251
+ );
252
+ if (missingRuleIds.length > 0) {
253
+ orphanedReferences.push({
254
+ ruleSetId: ruleSet.id,
255
+ ruleSetName: ruleSet.name,
256
+ missingRuleIds,
257
+ });
258
+ }
259
+ }
260
+
261
+ return {
262
+ success: errors.length === 0,
263
+ rules,
264
+ ruleSets,
265
+ errors,
266
+ idMapping,
267
+ orphanedReferences,
268
+ };
269
+ };
270
+
271
+ export const importFromJson = <
272
+ TContext = unknown,
273
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
274
+ >(
275
+ json: string,
276
+ options: ImportOptions = {},
277
+ ): ImportResult<TContext, TConsequences> => {
278
+ try {
279
+ const data = JSON.parse(json) as ExportFormat;
280
+ return importRules<TContext, TConsequences>(data, options);
281
+ } catch (error) {
282
+ return {
283
+ success: false,
284
+ rules: [],
285
+ ruleSets: [],
286
+ errors: [
287
+ {
288
+ index: -1,
289
+ type: "rule",
290
+ message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
291
+ },
292
+ ],
293
+ idMapping: new Map(),
294
+ orphanedReferences: [],
295
+ };
296
+ }
297
+ };
298
+
299
+ export const cloneRule = <
300
+ TContext = unknown,
301
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
302
+ >(
303
+ rule: Rule<TContext, TConsequences>,
304
+ newId?: string,
305
+ newName?: string,
306
+ ): Rule<TContext, TConsequences> => {
307
+ const now = new Date();
308
+ return {
309
+ ...rule,
310
+ id: newId ?? generateId(),
311
+ name: newName ?? `${rule.name} (Copy)`,
312
+ createdAt: now,
313
+ updatedAt: now,
314
+ };
315
+ };
316
+
317
+ export const mergeRuleSets = <
318
+ TContext = unknown,
319
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
320
+ >(
321
+ baseRules: ReadonlyArray<Rule<TContext, TConsequences>>,
322
+ incomingRules: ReadonlyArray<Rule<TContext, TConsequences>>,
323
+ strategy: "replace" | "skip" | "merge" = "replace",
324
+ ): ReadonlyArray<Rule<TContext, TConsequences>> => {
325
+ const ruleMap = new Map<string, Rule<TContext, TConsequences>>();
326
+
327
+ for (const rule of baseRules) {
328
+ ruleMap.set(rule.id, rule);
329
+ }
330
+
331
+ for (const rule of incomingRules) {
332
+ const existing = ruleMap.get(rule.id);
333
+
334
+ if (!existing) {
335
+ ruleMap.set(rule.id, rule);
336
+ } else {
337
+ switch (strategy) {
338
+ case "replace":
339
+ ruleMap.set(rule.id, rule);
340
+ break;
341
+ case "skip":
342
+ break;
343
+ case "merge": {
344
+ const merged: Rule<TContext, TConsequences> = {
345
+ ...existing,
346
+ ...rule,
347
+ tags: [...new Set([...existing.tags, ...rule.tags])],
348
+ metadata: { ...existing.metadata, ...rule.metadata },
349
+ updatedAt: new Date(),
350
+ };
351
+ ruleMap.set(rule.id, merged);
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ return [...ruleMap.values()];
359
+ };
360
+
361
+ export const diffRuleSets = <
362
+ TContext = unknown,
363
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
364
+ >(
365
+ oldRules: ReadonlyArray<Rule<TContext, TConsequences>>,
366
+ newRules: ReadonlyArray<Rule<TContext, TConsequences>>,
367
+ ): {
368
+ added: ReadonlyArray<Rule<TContext, TConsequences>>;
369
+ removed: ReadonlyArray<Rule<TContext, TConsequences>>;
370
+ modified: ReadonlyArray<{
371
+ old: Rule<TContext, TConsequences>;
372
+ new: Rule<TContext, TConsequences>;
373
+ }>;
374
+ unchanged: ReadonlyArray<Rule<TContext, TConsequences>>;
375
+ } => {
376
+ const oldMap = new Map(oldRules.map((r) => [r.id, r]));
377
+ const newMap = new Map(newRules.map((r) => [r.id, r]));
378
+
379
+ const added: Rule<TContext, TConsequences>[] = [];
380
+ const removed: Rule<TContext, TConsequences>[] = [];
381
+ const modified: Array<{
382
+ old: Rule<TContext, TConsequences>;
383
+ new: Rule<TContext, TConsequences>;
384
+ }> = [];
385
+ const unchanged: Rule<TContext, TConsequences>[] = [];
386
+
387
+ for (const [id, newRule] of newMap) {
388
+ const oldRule = oldMap.get(id);
389
+ if (!oldRule) {
390
+ added.push(newRule);
391
+ } else if (
392
+ JSON.stringify({ ...oldRule, updatedAt: null, createdAt: null }) !==
393
+ JSON.stringify({ ...newRule, updatedAt: null, createdAt: null })
394
+ ) {
395
+ modified.push({ old: oldRule, new: newRule });
396
+ } else {
397
+ unchanged.push(newRule);
398
+ }
399
+ }
400
+
401
+ for (const [id, oldRule] of oldMap) {
402
+ if (!newMap.has(id)) {
403
+ removed.push(oldRule);
404
+ }
405
+ }
406
+
407
+ return { added, removed, modified, unchanged };
408
+ };