@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,321 @@
|
|
|
1
|
+
import type { Condition, ConditionGroup } from "@f-o-t/condition-evaluator";
|
|
2
|
+
|
|
3
|
+
export type ConditionBuilderState = {
|
|
4
|
+
readonly conditions: (Condition | ConditionGroup)[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ConditionBuilder = {
|
|
8
|
+
readonly number: (
|
|
9
|
+
field: string,
|
|
10
|
+
operator: "gt" | "gte" | "lt" | "lte" | "eq" | "neq",
|
|
11
|
+
value: number,
|
|
12
|
+
) => ConditionBuilder;
|
|
13
|
+
|
|
14
|
+
readonly string: (
|
|
15
|
+
field: string,
|
|
16
|
+
operator:
|
|
17
|
+
| "eq"
|
|
18
|
+
| "neq"
|
|
19
|
+
| "contains"
|
|
20
|
+
| "not_contains"
|
|
21
|
+
| "starts_with"
|
|
22
|
+
| "ends_with"
|
|
23
|
+
| "in"
|
|
24
|
+
| "not_in"
|
|
25
|
+
| "like"
|
|
26
|
+
| "not_like"
|
|
27
|
+
| "ilike"
|
|
28
|
+
| "not_ilike"
|
|
29
|
+
| "regex"
|
|
30
|
+
| "not_regex",
|
|
31
|
+
value: string | string[],
|
|
32
|
+
) => ConditionBuilder;
|
|
33
|
+
|
|
34
|
+
readonly boolean: (
|
|
35
|
+
field: string,
|
|
36
|
+
operator: "eq" | "neq",
|
|
37
|
+
value: boolean,
|
|
38
|
+
) => ConditionBuilder;
|
|
39
|
+
|
|
40
|
+
readonly date: (
|
|
41
|
+
field: string,
|
|
42
|
+
operator: "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | "between",
|
|
43
|
+
value: string | Date | [string | Date, string | Date],
|
|
44
|
+
) => ConditionBuilder;
|
|
45
|
+
|
|
46
|
+
readonly array: (
|
|
47
|
+
field: string,
|
|
48
|
+
operator:
|
|
49
|
+
| "contains"
|
|
50
|
+
| "not_contains"
|
|
51
|
+
| "contains_all"
|
|
52
|
+
| "contains_any"
|
|
53
|
+
| "is_empty"
|
|
54
|
+
| "is_not_empty"
|
|
55
|
+
| "length_eq"
|
|
56
|
+
| "length_gt"
|
|
57
|
+
| "length_lt",
|
|
58
|
+
value: unknown,
|
|
59
|
+
) => ConditionBuilder;
|
|
60
|
+
|
|
61
|
+
readonly ref: (
|
|
62
|
+
field: string,
|
|
63
|
+
operator: string,
|
|
64
|
+
valueRef: string,
|
|
65
|
+
) => ConditionBuilder;
|
|
66
|
+
|
|
67
|
+
readonly and: (
|
|
68
|
+
builder: (cb: ConditionBuilder) => ConditionBuilder,
|
|
69
|
+
) => ConditionBuilder;
|
|
70
|
+
|
|
71
|
+
readonly or: (
|
|
72
|
+
builder: (cb: ConditionBuilder) => ConditionBuilder,
|
|
73
|
+
) => ConditionBuilder;
|
|
74
|
+
|
|
75
|
+
readonly raw: (condition: Condition | ConditionGroup) => ConditionBuilder;
|
|
76
|
+
|
|
77
|
+
readonly build: () => ConditionGroup;
|
|
78
|
+
|
|
79
|
+
readonly getConditions: () => ReadonlyArray<Condition | ConditionGroup>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let conditionIdCounter = 0;
|
|
83
|
+
let groupIdCounter = 0;
|
|
84
|
+
|
|
85
|
+
const generateConditionId = (): string => `cond-${++conditionIdCounter}`;
|
|
86
|
+
const generateGroupId = (): string => `group-${++groupIdCounter}`;
|
|
87
|
+
|
|
88
|
+
export const resetBuilderIds = (): void => {
|
|
89
|
+
conditionIdCounter = 0;
|
|
90
|
+
groupIdCounter = 0;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const createConditionBuilder = (
|
|
94
|
+
state: ConditionBuilderState = { conditions: [] },
|
|
95
|
+
operator: "AND" | "OR" = "AND",
|
|
96
|
+
): ConditionBuilder => {
|
|
97
|
+
const addCondition = (
|
|
98
|
+
condition: Condition | ConditionGroup,
|
|
99
|
+
): ConditionBuilder => {
|
|
100
|
+
return createConditionBuilder(
|
|
101
|
+
{ conditions: [...state.conditions, condition] },
|
|
102
|
+
operator,
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
number: (field, op, value) =>
|
|
108
|
+
addCondition({
|
|
109
|
+
id: generateConditionId(),
|
|
110
|
+
type: "number",
|
|
111
|
+
field,
|
|
112
|
+
operator: op,
|
|
113
|
+
value,
|
|
114
|
+
}),
|
|
115
|
+
|
|
116
|
+
string: (field, op, value) =>
|
|
117
|
+
addCondition({
|
|
118
|
+
id: generateConditionId(),
|
|
119
|
+
type: "string",
|
|
120
|
+
field,
|
|
121
|
+
operator: op,
|
|
122
|
+
value: value as string,
|
|
123
|
+
} as Condition),
|
|
124
|
+
|
|
125
|
+
boolean: (field, op, value) =>
|
|
126
|
+
addCondition({
|
|
127
|
+
id: generateConditionId(),
|
|
128
|
+
type: "boolean",
|
|
129
|
+
field,
|
|
130
|
+
operator: op,
|
|
131
|
+
value,
|
|
132
|
+
}),
|
|
133
|
+
|
|
134
|
+
date: (field, op, value) => {
|
|
135
|
+
const normalizedValue =
|
|
136
|
+
value instanceof Date
|
|
137
|
+
? value.toISOString()
|
|
138
|
+
: Array.isArray(value)
|
|
139
|
+
? value.map((v) => (v instanceof Date ? v.toISOString() : v))
|
|
140
|
+
: value;
|
|
141
|
+
|
|
142
|
+
return addCondition({
|
|
143
|
+
id: generateConditionId(),
|
|
144
|
+
type: "date",
|
|
145
|
+
field,
|
|
146
|
+
operator: op,
|
|
147
|
+
value: normalizedValue as string,
|
|
148
|
+
} as Condition);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
array: (field, op, value) =>
|
|
152
|
+
addCondition({
|
|
153
|
+
id: generateConditionId(),
|
|
154
|
+
type: "array",
|
|
155
|
+
field,
|
|
156
|
+
operator: op,
|
|
157
|
+
value,
|
|
158
|
+
} as Condition),
|
|
159
|
+
|
|
160
|
+
ref: (field, op, valueRef) =>
|
|
161
|
+
addCondition({
|
|
162
|
+
id: generateConditionId(),
|
|
163
|
+
type: "number",
|
|
164
|
+
field,
|
|
165
|
+
operator: op,
|
|
166
|
+
valueRef,
|
|
167
|
+
} as Condition),
|
|
168
|
+
|
|
169
|
+
and: (builderFn) => {
|
|
170
|
+
const nestedBuilder = createConditionBuilder(
|
|
171
|
+
{ conditions: [] },
|
|
172
|
+
"AND",
|
|
173
|
+
);
|
|
174
|
+
const result = builderFn(nestedBuilder);
|
|
175
|
+
return addCondition(result.build());
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
or: (builderFn) => {
|
|
179
|
+
const nestedBuilder = createConditionBuilder({ conditions: [] }, "OR");
|
|
180
|
+
const result = builderFn(nestedBuilder);
|
|
181
|
+
return addCondition(result.build());
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
raw: (condition) => addCondition(condition),
|
|
185
|
+
|
|
186
|
+
build: (): ConditionGroup => ({
|
|
187
|
+
id: generateGroupId(),
|
|
188
|
+
operator,
|
|
189
|
+
conditions: state.conditions,
|
|
190
|
+
}),
|
|
191
|
+
|
|
192
|
+
getConditions: () => state.conditions,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const conditions = (): ConditionBuilder =>
|
|
197
|
+
createConditionBuilder({ conditions: [] }, "AND");
|
|
198
|
+
|
|
199
|
+
export const and = (
|
|
200
|
+
builderFn: (cb: ConditionBuilder) => ConditionBuilder,
|
|
201
|
+
): ConditionGroup => {
|
|
202
|
+
const builder = createConditionBuilder({ conditions: [] }, "AND");
|
|
203
|
+
return builderFn(builder).build();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const or = (
|
|
207
|
+
builderFn: (cb: ConditionBuilder) => ConditionBuilder,
|
|
208
|
+
): ConditionGroup => {
|
|
209
|
+
const builder = createConditionBuilder({ conditions: [] }, "OR");
|
|
210
|
+
return builderFn(builder).build();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const all = (
|
|
214
|
+
...items: (Condition | ConditionGroup)[]
|
|
215
|
+
): ConditionGroup => ({
|
|
216
|
+
id: generateGroupId(),
|
|
217
|
+
operator: "AND",
|
|
218
|
+
conditions: items,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
export const any = (
|
|
222
|
+
...items: (Condition | ConditionGroup)[]
|
|
223
|
+
): ConditionGroup => ({
|
|
224
|
+
id: generateGroupId(),
|
|
225
|
+
operator: "OR",
|
|
226
|
+
conditions: items,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
export const num = (
|
|
230
|
+
field: string,
|
|
231
|
+
operator: "gt" | "gte" | "lt" | "lte" | "eq" | "neq",
|
|
232
|
+
value: number,
|
|
233
|
+
): Condition => ({
|
|
234
|
+
id: generateConditionId(),
|
|
235
|
+
type: "number",
|
|
236
|
+
field,
|
|
237
|
+
operator,
|
|
238
|
+
value,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
export const str = (
|
|
242
|
+
field: string,
|
|
243
|
+
operator:
|
|
244
|
+
| "eq"
|
|
245
|
+
| "neq"
|
|
246
|
+
| "contains"
|
|
247
|
+
| "not_contains"
|
|
248
|
+
| "starts_with"
|
|
249
|
+
| "ends_with"
|
|
250
|
+
| "in"
|
|
251
|
+
| "not_in"
|
|
252
|
+
| "like"
|
|
253
|
+
| "not_like"
|
|
254
|
+
| "ilike"
|
|
255
|
+
| "not_ilike"
|
|
256
|
+
| "regex"
|
|
257
|
+
| "not_regex",
|
|
258
|
+
value: string | string[],
|
|
259
|
+
): Condition =>
|
|
260
|
+
({
|
|
261
|
+
id: generateConditionId(),
|
|
262
|
+
type: "string",
|
|
263
|
+
field,
|
|
264
|
+
operator,
|
|
265
|
+
value,
|
|
266
|
+
}) as Condition;
|
|
267
|
+
|
|
268
|
+
export const bool = (
|
|
269
|
+
field: string,
|
|
270
|
+
operator: "eq" | "neq",
|
|
271
|
+
value: boolean,
|
|
272
|
+
): Condition => ({
|
|
273
|
+
id: generateConditionId(),
|
|
274
|
+
type: "boolean",
|
|
275
|
+
field,
|
|
276
|
+
operator,
|
|
277
|
+
value,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
export const date = (
|
|
281
|
+
field: string,
|
|
282
|
+
operator: "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | "between",
|
|
283
|
+
value: string | Date | [string | Date, string | Date],
|
|
284
|
+
): Condition => {
|
|
285
|
+
const normalizedValue =
|
|
286
|
+
value instanceof Date
|
|
287
|
+
? value.toISOString()
|
|
288
|
+
: Array.isArray(value)
|
|
289
|
+
? value.map((v) => (v instanceof Date ? v.toISOString() : v))
|
|
290
|
+
: value;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
id: generateConditionId(),
|
|
294
|
+
type: "date",
|
|
295
|
+
field,
|
|
296
|
+
operator,
|
|
297
|
+
value: normalizedValue as string,
|
|
298
|
+
} as Condition;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export const arr = (
|
|
302
|
+
field: string,
|
|
303
|
+
operator:
|
|
304
|
+
| "contains"
|
|
305
|
+
| "not_contains"
|
|
306
|
+
| "contains_all"
|
|
307
|
+
| "contains_any"
|
|
308
|
+
| "is_empty"
|
|
309
|
+
| "is_not_empty"
|
|
310
|
+
| "length_eq"
|
|
311
|
+
| "length_gt"
|
|
312
|
+
| "length_lt",
|
|
313
|
+
value?: unknown,
|
|
314
|
+
): Condition =>
|
|
315
|
+
({
|
|
316
|
+
id: generateConditionId(),
|
|
317
|
+
type: "array",
|
|
318
|
+
field,
|
|
319
|
+
operator,
|
|
320
|
+
value,
|
|
321
|
+
}) as Condition;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { ConditionGroup } from "@f-o-t/condition-evaluator";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type {
|
|
4
|
+
ConsequenceDefinitions,
|
|
5
|
+
DefaultConsequences,
|
|
6
|
+
} from "../types/consequence";
|
|
7
|
+
import type { RuleInput } from "../types/rule";
|
|
8
|
+
import {
|
|
9
|
+
type ConditionBuilder,
|
|
10
|
+
conditions as createConditions,
|
|
11
|
+
} from "./conditions";
|
|
12
|
+
|
|
13
|
+
export type RuleBuilderState<
|
|
14
|
+
_TContext = unknown,
|
|
15
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
16
|
+
> = {
|
|
17
|
+
readonly id?: string;
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
readonly description?: string;
|
|
20
|
+
readonly conditions?: ConditionGroup;
|
|
21
|
+
readonly consequences: Array<{
|
|
22
|
+
type: keyof TConsequences;
|
|
23
|
+
payload: z.infer<TConsequences[keyof TConsequences]>;
|
|
24
|
+
}>;
|
|
25
|
+
readonly priority: number;
|
|
26
|
+
readonly enabled: boolean;
|
|
27
|
+
readonly stopOnMatch: boolean;
|
|
28
|
+
readonly tags: string[];
|
|
29
|
+
readonly category?: string;
|
|
30
|
+
readonly metadata?: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type RuleBuilder<
|
|
34
|
+
TContext = unknown,
|
|
35
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
36
|
+
> = {
|
|
37
|
+
readonly id: (id: string) => RuleBuilder<TContext, TConsequences>;
|
|
38
|
+
|
|
39
|
+
readonly named: (name: string) => RuleBuilder<TContext, TConsequences>;
|
|
40
|
+
|
|
41
|
+
readonly describedAs: (
|
|
42
|
+
description: string,
|
|
43
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
44
|
+
|
|
45
|
+
readonly when: (
|
|
46
|
+
conditions: ConditionGroup | ((cb: ConditionBuilder) => ConditionBuilder),
|
|
47
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
48
|
+
|
|
49
|
+
readonly then: <K extends keyof TConsequences>(
|
|
50
|
+
type: K,
|
|
51
|
+
payload: z.infer<TConsequences[K]>,
|
|
52
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
53
|
+
|
|
54
|
+
readonly withPriority: (
|
|
55
|
+
priority: number,
|
|
56
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
57
|
+
|
|
58
|
+
readonly enabled: (
|
|
59
|
+
enabled?: boolean,
|
|
60
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
61
|
+
|
|
62
|
+
readonly disabled: () => RuleBuilder<TContext, TConsequences>;
|
|
63
|
+
|
|
64
|
+
readonly stopOnMatch: (
|
|
65
|
+
stop?: boolean,
|
|
66
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
67
|
+
|
|
68
|
+
readonly tagged: (...tags: string[]) => RuleBuilder<TContext, TConsequences>;
|
|
69
|
+
|
|
70
|
+
readonly inCategory: (
|
|
71
|
+
category: string,
|
|
72
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
73
|
+
|
|
74
|
+
readonly withMetadata: (
|
|
75
|
+
metadata: Record<string, unknown>,
|
|
76
|
+
) => RuleBuilder<TContext, TConsequences>;
|
|
77
|
+
|
|
78
|
+
readonly build: () => RuleInput<TContext, TConsequences>;
|
|
79
|
+
|
|
80
|
+
readonly getState: () => RuleBuilderState<TContext, TConsequences>;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const createRuleBuilder = <
|
|
84
|
+
TContext = unknown,
|
|
85
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
86
|
+
>(
|
|
87
|
+
state: RuleBuilderState<TContext, TConsequences> = {
|
|
88
|
+
consequences: [],
|
|
89
|
+
priority: 0,
|
|
90
|
+
enabled: true,
|
|
91
|
+
stopOnMatch: false,
|
|
92
|
+
tags: [],
|
|
93
|
+
},
|
|
94
|
+
): RuleBuilder<TContext, TConsequences> => {
|
|
95
|
+
const update = (
|
|
96
|
+
updates: Partial<RuleBuilderState<TContext, TConsequences>>,
|
|
97
|
+
): RuleBuilder<TContext, TConsequences> => {
|
|
98
|
+
return createRuleBuilder({ ...state, ...updates });
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
id: (id) => update({ id }),
|
|
103
|
+
|
|
104
|
+
named: (name) => update({ name }),
|
|
105
|
+
|
|
106
|
+
describedAs: (description) => update({ description }),
|
|
107
|
+
|
|
108
|
+
when: (conditionsOrBuilder) => {
|
|
109
|
+
if (typeof conditionsOrBuilder === "function") {
|
|
110
|
+
const builder = createConditions();
|
|
111
|
+
const result = conditionsOrBuilder(builder);
|
|
112
|
+
return update({ conditions: result.build() });
|
|
113
|
+
}
|
|
114
|
+
return update({ conditions: conditionsOrBuilder });
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// biome-ignore lint/suspicious/noThenProperty: Intentional fluent API method for rule builder (when().then())
|
|
118
|
+
then: (type, payload) => {
|
|
119
|
+
return update({
|
|
120
|
+
consequences: [
|
|
121
|
+
...state.consequences,
|
|
122
|
+
{ type, payload } as {
|
|
123
|
+
type: keyof TConsequences;
|
|
124
|
+
payload: z.infer<TConsequences[keyof TConsequences]>;
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
withPriority: (priority) => update({ priority }),
|
|
131
|
+
|
|
132
|
+
enabled: (enabled = true) => update({ enabled }),
|
|
133
|
+
|
|
134
|
+
disabled: () => update({ enabled: false }),
|
|
135
|
+
|
|
136
|
+
stopOnMatch: (stop = true) => update({ stopOnMatch: stop }),
|
|
137
|
+
|
|
138
|
+
tagged: (...tags) => update({ tags: [...state.tags, ...tags] }),
|
|
139
|
+
|
|
140
|
+
inCategory: (category) => update({ category }),
|
|
141
|
+
|
|
142
|
+
withMetadata: (metadata) =>
|
|
143
|
+
update({ metadata: { ...state.metadata, ...metadata } }),
|
|
144
|
+
|
|
145
|
+
build: (): RuleInput<TContext, TConsequences> => {
|
|
146
|
+
if (!state.name) {
|
|
147
|
+
throw new Error("Rule must have a name");
|
|
148
|
+
}
|
|
149
|
+
if (!state.conditions) {
|
|
150
|
+
throw new Error("Rule must have conditions");
|
|
151
|
+
}
|
|
152
|
+
if (state.consequences.length === 0) {
|
|
153
|
+
throw new Error("Rule must have at least one consequence");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id: state.id,
|
|
158
|
+
name: state.name,
|
|
159
|
+
description: state.description,
|
|
160
|
+
conditions: state.conditions,
|
|
161
|
+
consequences: state.consequences,
|
|
162
|
+
priority: state.priority,
|
|
163
|
+
enabled: state.enabled,
|
|
164
|
+
stopOnMatch: state.stopOnMatch,
|
|
165
|
+
tags: state.tags,
|
|
166
|
+
category: state.category,
|
|
167
|
+
metadata: state.metadata,
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
getState: () => state,
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const rule = <
|
|
176
|
+
TContext = unknown,
|
|
177
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
178
|
+
>(): RuleBuilder<TContext, TConsequences> => {
|
|
179
|
+
return createRuleBuilder<TContext, TConsequences>();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const createRule = <
|
|
183
|
+
TContext = unknown,
|
|
184
|
+
TConsequences extends ConsequenceDefinitions = DefaultConsequences,
|
|
185
|
+
>(
|
|
186
|
+
builderFn: (
|
|
187
|
+
rb: RuleBuilder<TContext, TConsequences>,
|
|
188
|
+
) => RuleBuilder<TContext, TConsequences>,
|
|
189
|
+
): RuleInput<TContext, TConsequences> => {
|
|
190
|
+
const builder = createRuleBuilder<TContext, TConsequences>();
|
|
191
|
+
return builderFn(builder).build();
|
|
192
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { CacheStats } from "../types/state";
|
|
2
|
+
|
|
3
|
+
export type CacheEntry<T> = {
|
|
4
|
+
value: T;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type Cache<T> = {
|
|
10
|
+
readonly get: (key: string) => T | undefined;
|
|
11
|
+
readonly set: (key: string, value: T) => void;
|
|
12
|
+
readonly has: (key: string) => boolean;
|
|
13
|
+
readonly delete: (key: string) => boolean;
|
|
14
|
+
readonly clear: () => void;
|
|
15
|
+
readonly getStats: () => CacheStats;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CacheOptions = {
|
|
19
|
+
readonly ttl: number;
|
|
20
|
+
readonly maxSize: number;
|
|
21
|
+
readonly onEvict?: (key: string, value: unknown) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const createCache = <T>(options: CacheOptions): Cache<T> => {
|
|
25
|
+
const entries = new Map<string, CacheEntry<T>>();
|
|
26
|
+
let hits = 0;
|
|
27
|
+
let misses = 0;
|
|
28
|
+
let evictions = 0;
|
|
29
|
+
|
|
30
|
+
const isExpired = (entry: CacheEntry<T>): boolean => {
|
|
31
|
+
return Date.now() > entry.expiresAt;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const evictExpired = (): void => {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [key, entry] of entries) {
|
|
37
|
+
if (now > entry.expiresAt) {
|
|
38
|
+
entries.delete(key);
|
|
39
|
+
evictions++;
|
|
40
|
+
options.onEvict?.(key, entry.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const evictOldest = (): void => {
|
|
46
|
+
if (entries.size === 0) return;
|
|
47
|
+
|
|
48
|
+
// ES6 Maps maintain insertion order - first key is oldest (O(1) lookup)
|
|
49
|
+
const oldestKey = entries.keys().next().value;
|
|
50
|
+
if (oldestKey !== undefined) {
|
|
51
|
+
const entry = entries.get(oldestKey);
|
|
52
|
+
entries.delete(oldestKey);
|
|
53
|
+
evictions++;
|
|
54
|
+
if (entry) {
|
|
55
|
+
options.onEvict?.(oldestKey, entry.value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const get = (key: string): T | undefined => {
|
|
61
|
+
const entry = entries.get(key);
|
|
62
|
+
|
|
63
|
+
if (!entry) {
|
|
64
|
+
misses++;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isExpired(entry)) {
|
|
69
|
+
entries.delete(key);
|
|
70
|
+
evictions++;
|
|
71
|
+
options.onEvict?.(key, entry.value);
|
|
72
|
+
misses++;
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
hits++;
|
|
77
|
+
return entry.value;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const set = (key: string, value: T): void => {
|
|
81
|
+
evictExpired();
|
|
82
|
+
|
|
83
|
+
while (entries.size >= options.maxSize) {
|
|
84
|
+
evictOldest();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
entries.set(key, {
|
|
89
|
+
value,
|
|
90
|
+
expiresAt: now + options.ttl,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const has = (key: string): boolean => {
|
|
96
|
+
const entry = entries.get(key);
|
|
97
|
+
if (!entry) return false;
|
|
98
|
+
if (isExpired(entry)) {
|
|
99
|
+
entries.delete(key);
|
|
100
|
+
evictions++;
|
|
101
|
+
options.onEvict?.(key, entry.value);
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const deleteEntry = (key: string): boolean => {
|
|
108
|
+
return entries.delete(key);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const clear = (): void => {
|
|
112
|
+
entries.clear();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const getStats = (): CacheStats => {
|
|
116
|
+
const totalRequests = hits + misses;
|
|
117
|
+
return {
|
|
118
|
+
size: entries.size,
|
|
119
|
+
maxSize: options.maxSize,
|
|
120
|
+
hits,
|
|
121
|
+
misses,
|
|
122
|
+
hitRate: totalRequests > 0 ? hits / totalRequests : 0,
|
|
123
|
+
evictions,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
get,
|
|
129
|
+
set,
|
|
130
|
+
has,
|
|
131
|
+
delete: deleteEntry,
|
|
132
|
+
clear,
|
|
133
|
+
getStats,
|
|
134
|
+
};
|
|
135
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CacheStats } from "../types/state";
|
|
2
|
+
import type { Cache } from "./cache";
|
|
3
|
+
|
|
4
|
+
export const createNoopCache = <T>(): Cache<T> => {
|
|
5
|
+
return {
|
|
6
|
+
get: () => undefined,
|
|
7
|
+
set: () => {},
|
|
8
|
+
has: () => false,
|
|
9
|
+
delete: () => false,
|
|
10
|
+
clear: () => {},
|
|
11
|
+
getStats: (): CacheStats => ({
|
|
12
|
+
size: 0,
|
|
13
|
+
maxSize: 0,
|
|
14
|
+
hits: 0,
|
|
15
|
+
misses: 0,
|
|
16
|
+
hitRate: 0,
|
|
17
|
+
evictions: 0,
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
};
|