@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,386 @@
1
+ import {
2
+ type Condition,
3
+ type ConditionGroup,
4
+ isConditionGroup,
5
+ } from "@f-o-t/condition-evaluator";
6
+ import { z } from "zod";
7
+ import type {
8
+ ConsequenceDefinitions,
9
+ DefaultConsequences,
10
+ } from "../types/consequence";
11
+ import { type Rule, RuleSchema, RuleSetSchema } from "../types/rule";
12
+
13
+ // ============================================================================
14
+ // Zod Schemas - Define schemas first, then infer types
15
+ // ============================================================================
16
+
17
+ export const ValidationErrorSchema = z.object({
18
+ path: z.string(),
19
+ message: z.string(),
20
+ code: z.string(),
21
+ });
22
+ export type ValidationError = z.infer<typeof ValidationErrorSchema>;
23
+
24
+ export const ValidationResultSchema = z.object({
25
+ valid: z.boolean(),
26
+ errors: z.array(ValidationErrorSchema),
27
+ });
28
+ export type ValidationResult = z.infer<typeof ValidationResultSchema>;
29
+
30
+ export const ValidationOptionsSchema = z.object({
31
+ validateConditions: z.boolean().optional(),
32
+ validateConsequences: z.boolean().optional(),
33
+ strictMode: z.boolean().optional(),
34
+ });
35
+ export type ValidationOptions = z.infer<typeof ValidationOptionsSchema>;
36
+
37
+ // Resolved options with defaults applied
38
+ export type ResolvedValidationOptions = {
39
+ validateConditions: boolean;
40
+ validateConsequences: boolean;
41
+ strictMode: boolean;
42
+ };
43
+
44
+ // ============================================================================
45
+ // Default options (derived from schema)
46
+ // ============================================================================
47
+
48
+ const DEFAULT_OPTIONS: ValidationOptions = ValidationOptionsSchema.parse({});
49
+
50
+ const createError = (
51
+ path: string,
52
+ message: string,
53
+ code: string,
54
+ ): ValidationError => ({
55
+ path,
56
+ message,
57
+ code,
58
+ });
59
+
60
+ const validResult = (): ValidationResult => ({
61
+ valid: true,
62
+ errors: [],
63
+ });
64
+
65
+ const invalidResult = (
66
+ errors: ReadonlyArray<ValidationError>,
67
+ ): ValidationResult => ({
68
+ valid: false,
69
+ errors: [...errors],
70
+ });
71
+
72
+ const validateConditionStructure = (
73
+ condition: Condition | ConditionGroup,
74
+ path: string,
75
+ ): ReadonlyArray<ValidationError> => {
76
+ const errors: ValidationError[] = [];
77
+
78
+ if (isConditionGroup(condition)) {
79
+ if (!condition.id || typeof condition.id !== "string") {
80
+ errors.push(
81
+ createError(
82
+ `${path}.id`,
83
+ "Condition group must have a string id",
84
+ "MISSING_GROUP_ID",
85
+ ),
86
+ );
87
+ }
88
+
89
+ if (!["AND", "OR"].includes(condition.operator)) {
90
+ errors.push(
91
+ createError(
92
+ `${path}.operator`,
93
+ `Invalid operator: ${condition.operator}. Must be "AND" or "OR"`,
94
+ "INVALID_GROUP_OPERATOR",
95
+ ),
96
+ );
97
+ }
98
+
99
+ if (!Array.isArray(condition.conditions)) {
100
+ errors.push(
101
+ createError(
102
+ `${path}.conditions`,
103
+ "Condition group must have a conditions array",
104
+ "MISSING_CONDITIONS_ARRAY",
105
+ ),
106
+ );
107
+ } else if (condition.conditions.length === 0) {
108
+ errors.push(
109
+ createError(
110
+ `${path}.conditions`,
111
+ "Condition group must have at least one condition",
112
+ "EMPTY_CONDITIONS_ARRAY",
113
+ ),
114
+ );
115
+ } else {
116
+ for (let i = 0; i < condition.conditions.length; i++) {
117
+ const nestedErrors = validateConditionStructure(
118
+ condition.conditions[i] as Condition | ConditionGroup,
119
+ `${path}.conditions[${i}]`,
120
+ );
121
+ errors.push(...nestedErrors);
122
+ }
123
+ }
124
+ } else {
125
+ if (!condition.id || typeof condition.id !== "string") {
126
+ errors.push(
127
+ createError(
128
+ `${path}.id`,
129
+ "Condition must have a string id",
130
+ "MISSING_CONDITION_ID",
131
+ ),
132
+ );
133
+ }
134
+
135
+ if (!condition.type || typeof condition.type !== "string") {
136
+ errors.push(
137
+ createError(
138
+ `${path}.type`,
139
+ "Condition must have a type",
140
+ "MISSING_CONDITION_TYPE",
141
+ ),
142
+ );
143
+ }
144
+
145
+ if (!condition.field || typeof condition.field !== "string") {
146
+ errors.push(
147
+ createError(
148
+ `${path}.field`,
149
+ "Condition must have a field",
150
+ "MISSING_CONDITION_FIELD",
151
+ ),
152
+ );
153
+ }
154
+
155
+ if (!condition.operator || typeof condition.operator !== "string") {
156
+ errors.push(
157
+ createError(
158
+ `${path}.operator`,
159
+ "Condition must have an operator",
160
+ "MISSING_CONDITION_OPERATOR",
161
+ ),
162
+ );
163
+ }
164
+ }
165
+
166
+ return errors;
167
+ };
168
+
169
+ const validateConsequenceStructure = <
170
+ TConsequences extends ConsequenceDefinitions,
171
+ >(
172
+ consequences: ReadonlyArray<{ type: unknown; payload: unknown }>,
173
+ consequenceSchemas?: TConsequences,
174
+ strictMode = false,
175
+ ): ReadonlyArray<ValidationError> => {
176
+ const errors: ValidationError[] = [];
177
+
178
+ for (let i = 0; i < consequences.length; i++) {
179
+ const consequence = consequences[i];
180
+ if (!consequence) continue;
181
+ const path = `consequences[${i}]`;
182
+
183
+ if (!consequence.type || typeof consequence.type !== "string") {
184
+ errors.push(
185
+ createError(
186
+ `${path}.type`,
187
+ "Consequence must have a type",
188
+ "MISSING_CONSEQUENCE_TYPE",
189
+ ),
190
+ );
191
+ continue;
192
+ }
193
+
194
+ if (strictMode && consequenceSchemas) {
195
+ const schema = consequenceSchemas[consequence.type as string];
196
+ if (!schema) {
197
+ errors.push(
198
+ createError(
199
+ `${path}.type`,
200
+ `Unknown consequence type: ${consequence.type}`,
201
+ "UNKNOWN_CONSEQUENCE_TYPE",
202
+ ),
203
+ );
204
+ } else {
205
+ const result = schema.safeParse(consequence.payload);
206
+ if (!result.success) {
207
+ for (const issue of result.error.issues) {
208
+ errors.push(
209
+ createError(
210
+ `${path}.payload.${issue.path.join(".")}`,
211
+ issue.message,
212
+ "INVALID_CONSEQUENCE_PAYLOAD",
213
+ ),
214
+ );
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ return errors;
222
+ };
223
+
224
+ export const validateRule = <
225
+ TContext = unknown,
226
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
227
+ >(
228
+ rule: unknown,
229
+ options: ValidationOptions & { consequenceSchemas?: TConsequences } = {},
230
+ ): ValidationResult => {
231
+ const opts = { ...DEFAULT_OPTIONS, ...options };
232
+ const errors: ValidationError[] = [];
233
+
234
+ const schemaResult = RuleSchema.safeParse(rule);
235
+ if (!schemaResult.success) {
236
+ for (const issue of schemaResult.error.issues) {
237
+ errors.push(
238
+ createError(
239
+ issue.path.join("."),
240
+ issue.message,
241
+ "SCHEMA_VALIDATION_FAILED",
242
+ ),
243
+ );
244
+ }
245
+ return invalidResult(errors);
246
+ }
247
+
248
+ const validRule = schemaResult.data as Rule<TContext, TConsequences>;
249
+
250
+ if (opts.validateConditions) {
251
+ const conditionErrors = validateConditionStructure(
252
+ validRule.conditions,
253
+ "conditions",
254
+ );
255
+ errors.push(...conditionErrors);
256
+ }
257
+
258
+ if (opts.validateConsequences || opts.strictMode) {
259
+ const consequenceErrors = validateConsequenceStructure(
260
+ validRule.consequences as ReadonlyArray<{
261
+ type: unknown;
262
+ payload: unknown;
263
+ }>,
264
+ opts.consequenceSchemas,
265
+ opts.strictMode ?? false,
266
+ );
267
+ errors.push(...consequenceErrors);
268
+ }
269
+
270
+ return errors.length > 0 ? invalidResult(errors) : validResult();
271
+ };
272
+
273
+ export const validateRules = <
274
+ TContext = unknown,
275
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
276
+ >(
277
+ rules: ReadonlyArray<unknown>,
278
+ options: ValidationOptions & { consequenceSchemas?: TConsequences } = {},
279
+ ): ValidationResult => {
280
+ const errors: ValidationError[] = [];
281
+
282
+ for (let i = 0; i < rules.length; i++) {
283
+ const result = validateRule<TContext, TConsequences>(rules[i], options);
284
+ if (!result.valid) {
285
+ for (const error of result.errors) {
286
+ errors.push(
287
+ createError(
288
+ `rules[${i}].${error.path}`,
289
+ error.message,
290
+ error.code,
291
+ ),
292
+ );
293
+ }
294
+ }
295
+ }
296
+
297
+ return errors.length > 0 ? invalidResult(errors) : validResult();
298
+ };
299
+
300
+ export const validateRuleSet = (ruleSet: unknown): ValidationResult => {
301
+ const errors: ValidationError[] = [];
302
+
303
+ const schemaResult = RuleSetSchema.safeParse(ruleSet);
304
+ if (!schemaResult.success) {
305
+ for (const issue of schemaResult.error.issues) {
306
+ errors.push(
307
+ createError(
308
+ issue.path.join("."),
309
+ issue.message,
310
+ "SCHEMA_VALIDATION_FAILED",
311
+ ),
312
+ );
313
+ }
314
+ return invalidResult(errors);
315
+ }
316
+
317
+ return validResult();
318
+ };
319
+
320
+ export const validateConditions = (
321
+ conditions: ConditionGroup,
322
+ ): ValidationResult => {
323
+ const errors = validateConditionStructure(conditions, "conditions");
324
+ return errors.length > 0 ? invalidResult(errors) : validResult();
325
+ };
326
+
327
+ export const parseRule = <
328
+ TContext = unknown,
329
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
330
+ >(
331
+ rule: unknown,
332
+ options: ValidationOptions & { consequenceSchemas?: TConsequences } = {},
333
+ ): Rule<TContext, TConsequences> => {
334
+ const result = validateRule<TContext, TConsequences>(rule, options);
335
+ if (!result.valid) {
336
+ throw new Error(
337
+ `Invalid rule: ${result.errors.map((e) => e.message).join(", ")}`,
338
+ );
339
+ }
340
+ return rule as Rule<TContext, TConsequences>;
341
+ };
342
+
343
+ export const safeParseRule = <
344
+ TContext = unknown,
345
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
346
+ >(
347
+ rule: unknown,
348
+ options: ValidationOptions & { consequenceSchemas?: TConsequences } = {},
349
+ ):
350
+ | { success: true; data: Rule<TContext, TConsequences> }
351
+ | { success: false; errors: ReadonlyArray<ValidationError> } => {
352
+ const result = validateRule<TContext, TConsequences>(rule, options);
353
+ if (!result.valid) {
354
+ return { success: false, errors: result.errors };
355
+ }
356
+ return { success: true, data: rule as Rule<TContext, TConsequences> };
357
+ };
358
+
359
+ export const createRuleValidator = <
360
+ TContext = unknown,
361
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
362
+ >(
363
+ consequenceSchemas: TConsequences,
364
+ defaultOptions: ValidationOptions = {},
365
+ ) => {
366
+ return {
367
+ validate: (rule: unknown, options: ValidationOptions = {}) =>
368
+ validateRule<TContext, TConsequences>(rule, {
369
+ ...defaultOptions,
370
+ ...options,
371
+ consequenceSchemas,
372
+ }),
373
+ parse: (rule: unknown, options: ValidationOptions = {}) =>
374
+ parseRule<TContext, TConsequences>(rule, {
375
+ ...defaultOptions,
376
+ ...options,
377
+ consequenceSchemas,
378
+ }),
379
+ safeParse: (rule: unknown, options: ValidationOptions = {}) =>
380
+ safeParseRule<TContext, TConsequences>(rule, {
381
+ ...defaultOptions,
382
+ ...options,
383
+ consequenceSchemas,
384
+ }),
385
+ };
386
+ };
@@ -0,0 +1,337 @@
1
+ import type {
2
+ ConsequenceDefinitions,
3
+ DefaultConsequences,
4
+ } from "../types/consequence";
5
+ import type { Rule } from "../types/rule";
6
+ import { generateId } from "../utils/id";
7
+
8
+ export type RuleVersion<
9
+ TContext = unknown,
10
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
11
+ > = {
12
+ readonly versionId: string;
13
+ readonly ruleId: string;
14
+ readonly version: number;
15
+ readonly rule: Rule<TContext, TConsequences>;
16
+ readonly createdAt: Date;
17
+ readonly createdBy?: string;
18
+ readonly comment?: string;
19
+ readonly changeType: "create" | "update" | "delete" | "enable" | "disable";
20
+ };
21
+
22
+ export type VersionHistory<
23
+ TContext = unknown,
24
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
25
+ > = {
26
+ readonly ruleId: string;
27
+ readonly currentVersion: number;
28
+ readonly versions: ReadonlyArray<RuleVersion<TContext, TConsequences>>;
29
+ };
30
+
31
+ export type VersionStore<
32
+ TContext = unknown,
33
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
34
+ > = {
35
+ readonly histories: ReadonlyMap<
36
+ string,
37
+ VersionHistory<TContext, TConsequences>
38
+ >;
39
+ };
40
+
41
+ export const createVersionStore = <
42
+ TContext = unknown,
43
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
44
+ >(): VersionStore<TContext, TConsequences> => ({
45
+ histories: new Map(),
46
+ });
47
+
48
+ export const addVersion = <
49
+ TContext = unknown,
50
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
51
+ >(
52
+ store: VersionStore<TContext, TConsequences>,
53
+ rule: Rule<TContext, TConsequences>,
54
+ changeType: RuleVersion<TContext, TConsequences>["changeType"],
55
+ options: { createdBy?: string; comment?: string } = {},
56
+ ): VersionStore<TContext, TConsequences> => {
57
+ const histories = new Map(store.histories);
58
+ const existingHistory = histories.get(rule.id);
59
+
60
+ const currentVersion = existingHistory
61
+ ? existingHistory.currentVersion + 1
62
+ : 1;
63
+
64
+ const version: RuleVersion<TContext, TConsequences> = {
65
+ versionId: generateId(),
66
+ ruleId: rule.id,
67
+ version: currentVersion,
68
+ rule: { ...rule },
69
+ createdAt: new Date(),
70
+ createdBy: options.createdBy,
71
+ comment: options.comment,
72
+ changeType,
73
+ };
74
+
75
+ const newHistory: VersionHistory<TContext, TConsequences> = {
76
+ ruleId: rule.id,
77
+ currentVersion,
78
+ versions: existingHistory
79
+ ? [...existingHistory.versions, version]
80
+ : [version],
81
+ };
82
+
83
+ histories.set(rule.id, newHistory);
84
+
85
+ return { histories };
86
+ };
87
+
88
+ export const getHistory = <
89
+ TContext = unknown,
90
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
91
+ >(
92
+ store: VersionStore<TContext, TConsequences>,
93
+ ruleId: string,
94
+ ): VersionHistory<TContext, TConsequences> | undefined => {
95
+ return store.histories.get(ruleId);
96
+ };
97
+
98
+ export const getVersion = <
99
+ TContext = unknown,
100
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
101
+ >(
102
+ store: VersionStore<TContext, TConsequences>,
103
+ ruleId: string,
104
+ version: number,
105
+ ): RuleVersion<TContext, TConsequences> | undefined => {
106
+ const history = store.histories.get(ruleId);
107
+ if (!history) return undefined;
108
+ return history.versions.find((v) => v.version === version);
109
+ };
110
+
111
+ export const getLatestVersion = <
112
+ TContext = unknown,
113
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
114
+ >(
115
+ store: VersionStore<TContext, TConsequences>,
116
+ ruleId: string,
117
+ ): RuleVersion<TContext, TConsequences> | undefined => {
118
+ const history = store.histories.get(ruleId);
119
+ if (!history || history.versions.length === 0) return undefined;
120
+ return history.versions[history.versions.length - 1];
121
+ };
122
+
123
+ export const getAllVersions = <
124
+ TContext = unknown,
125
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
126
+ >(
127
+ store: VersionStore<TContext, TConsequences>,
128
+ ruleId: string,
129
+ ): ReadonlyArray<RuleVersion<TContext, TConsequences>> => {
130
+ const history = store.histories.get(ruleId);
131
+ return history?.versions ?? [];
132
+ };
133
+
134
+ export const rollbackToVersion = <
135
+ TContext = unknown,
136
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
137
+ >(
138
+ store: VersionStore<TContext, TConsequences>,
139
+ ruleId: string,
140
+ targetVersion: number,
141
+ options: { createdBy?: string; comment?: string } = {},
142
+ ): {
143
+ store: VersionStore<TContext, TConsequences>;
144
+ rule: Rule<TContext, TConsequences> | undefined;
145
+ } => {
146
+ const targetVersionRecord = getVersion(store, ruleId, targetVersion);
147
+ if (!targetVersionRecord) {
148
+ return { store, rule: undefined };
149
+ }
150
+
151
+ const rolledBackRule: Rule<TContext, TConsequences> = {
152
+ ...targetVersionRecord.rule,
153
+ updatedAt: new Date(),
154
+ };
155
+
156
+ const newStore = addVersion(store, rolledBackRule, "update", {
157
+ createdBy: options.createdBy,
158
+ comment: options.comment ?? `Rollback to version ${targetVersion}`,
159
+ });
160
+
161
+ return { store: newStore, rule: rolledBackRule };
162
+ };
163
+
164
+ export const compareVersions = <
165
+ TContext = unknown,
166
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
167
+ >(
168
+ store: VersionStore<TContext, TConsequences>,
169
+ ruleId: string,
170
+ version1: number,
171
+ version2: number,
172
+ ): {
173
+ version1: RuleVersion<TContext, TConsequences> | undefined;
174
+ version2: RuleVersion<TContext, TConsequences> | undefined;
175
+ differences: ReadonlyArray<{
176
+ field: string;
177
+ oldValue: unknown;
178
+ newValue: unknown;
179
+ }>;
180
+ } | null => {
181
+ const v1 = getVersion(store, ruleId, version1);
182
+ const v2 = getVersion(store, ruleId, version2);
183
+
184
+ if (!v1 || !v2) {
185
+ return null;
186
+ }
187
+
188
+ const differences: Array<{
189
+ field: string;
190
+ oldValue: unknown;
191
+ newValue: unknown;
192
+ }> = [];
193
+
194
+ const compareFields = [
195
+ "name",
196
+ "description",
197
+ "priority",
198
+ "enabled",
199
+ "stopOnMatch",
200
+ "category",
201
+ ] as const;
202
+
203
+ for (const field of compareFields) {
204
+ const oldValue = v1.rule[field];
205
+ const newValue = v2.rule[field];
206
+ if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
207
+ differences.push({ field, oldValue, newValue });
208
+ }
209
+ }
210
+
211
+ if (
212
+ JSON.stringify(v1.rule.conditions) !== JSON.stringify(v2.rule.conditions)
213
+ ) {
214
+ differences.push({
215
+ field: "conditions",
216
+ oldValue: v1.rule.conditions,
217
+ newValue: v2.rule.conditions,
218
+ });
219
+ }
220
+
221
+ if (
222
+ JSON.stringify(v1.rule.consequences) !==
223
+ JSON.stringify(v2.rule.consequences)
224
+ ) {
225
+ differences.push({
226
+ field: "consequences",
227
+ oldValue: v1.rule.consequences,
228
+ newValue: v2.rule.consequences,
229
+ });
230
+ }
231
+
232
+ if (JSON.stringify(v1.rule.tags) !== JSON.stringify(v2.rule.tags)) {
233
+ differences.push({
234
+ field: "tags",
235
+ oldValue: v1.rule.tags,
236
+ newValue: v2.rule.tags,
237
+ });
238
+ }
239
+
240
+ return { version1: v1, version2: v2, differences };
241
+ };
242
+
243
+ export const getVersionsByDateRange = <
244
+ TContext = unknown,
245
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
246
+ >(
247
+ store: VersionStore<TContext, TConsequences>,
248
+ startDate: Date,
249
+ endDate: Date,
250
+ ): ReadonlyArray<RuleVersion<TContext, TConsequences>> => {
251
+ const versions: RuleVersion<TContext, TConsequences>[] = [];
252
+
253
+ for (const history of store.histories.values()) {
254
+ for (const version of history.versions) {
255
+ if (version.createdAt >= startDate && version.createdAt <= endDate) {
256
+ versions.push(version);
257
+ }
258
+ }
259
+ }
260
+
261
+ return versions.sort(
262
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
263
+ );
264
+ };
265
+
266
+ export const getVersionsByChangeType = <
267
+ TContext = unknown,
268
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
269
+ >(
270
+ store: VersionStore<TContext, TConsequences>,
271
+ changeType: RuleVersion<TContext, TConsequences>["changeType"],
272
+ ): ReadonlyArray<RuleVersion<TContext, TConsequences>> => {
273
+ const versions: RuleVersion<TContext, TConsequences>[] = [];
274
+
275
+ for (const history of store.histories.values()) {
276
+ for (const version of history.versions) {
277
+ if (version.changeType === changeType) {
278
+ versions.push(version);
279
+ }
280
+ }
281
+ }
282
+
283
+ return versions;
284
+ };
285
+
286
+ export const pruneOldVersions = <
287
+ TContext = unknown,
288
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
289
+ >(
290
+ store: VersionStore<TContext, TConsequences>,
291
+ keepCount: number,
292
+ ): VersionStore<TContext, TConsequences> => {
293
+ const histories = new Map<string, VersionHistory<TContext, TConsequences>>();
294
+
295
+ for (const [ruleId, history] of store.histories) {
296
+ const prunedVersions =
297
+ history.versions.length > keepCount
298
+ ? history.versions.slice(-keepCount)
299
+ : history.versions;
300
+
301
+ histories.set(ruleId, {
302
+ ruleId,
303
+ currentVersion: history.currentVersion,
304
+ versions: prunedVersions,
305
+ });
306
+ }
307
+
308
+ return { histories };
309
+ };
310
+
311
+ export const formatVersionHistory = <
312
+ TContext = unknown,
313
+ TConsequences extends ConsequenceDefinitions = DefaultConsequences,
314
+ >(
315
+ history: VersionHistory<TContext, TConsequences>,
316
+ ): string => {
317
+ const lines: string[] = [
318
+ `=== Version History for Rule: ${history.ruleId} ===`,
319
+ `Current Version: ${history.currentVersion}`,
320
+ `Total Versions: ${history.versions.length}`,
321
+ "",
322
+ ];
323
+
324
+ for (const version of history.versions) {
325
+ lines.push(
326
+ `v${version.version} - ${version.changeType} (${version.createdAt.toISOString()})`,
327
+ );
328
+ if (version.createdBy) {
329
+ lines.push(` By: ${version.createdBy}`);
330
+ }
331
+ if (version.comment) {
332
+ lines.push(` Comment: ${version.comment}`);
333
+ }
334
+ }
335
+
336
+ return lines.join("\n");
337
+ };