Skip to content

Commit 15eab5a

Browse files
committed
Start writing tests in typescript
1 parent 493f044 commit 15eab5a

File tree

15 files changed

+650
-321
lines changed

15 files changed

+650
-321
lines changed

.eslintrc.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ module.exports = {
5555
'plugin:@typescript-eslint/eslint-recommended',
5656
'plugin:@typescript-eslint/recommended',
5757
],
58+
settings: {
59+
'import/parsers': {
60+
'@typescript-eslint/parser': ['.ts', '.tsx'],
61+
},
62+
'import/resolver': {
63+
typescript: {},
64+
},
65+
},
66+
rules: {
67+
'@typescript-eslint/explicit-module-boundary-types': 0,
68+
},
5869
},
5970
{
6071
files: ['**/test/**/*.[t|j]s'],

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ The default mechanism of resolution of an interpolated property is simple dot-no
357357

358358
In addition to `context`, actions have a special property called `results` that can be used for interpolation. Read more about results context [here](tbd)
359359

360-
## Events
360+
### Events
361361

362362
The rules engine is also an event emitter. There are 4 types of events you can listen to
363363

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"author": "Adam Jenkins",
77
"license": "MIT",
88
"browser": "build/bundle.min.js",
9+
"types": "index.d.ts",
910
"keywords": [
1011
"json schema",
1112
"rules engine"
@@ -15,7 +16,12 @@
1516
"index.d.ts"
1617
],
1718
"scripts": {
18-
"build": "rimraf build && babel src/index.js -d build && rollup -c ",
19+
"dist": "yarn lint && yarn test && yarn build",
20+
"clean": "rimraf build",
21+
"build": "yarn clean && yarn babel && rollup -c",
22+
"babel": "babel src -d build --copy-files --no-copy-ignored",
23+
"lint": "eslint src/",
24+
"test:ci": "yarn test --coverage --coverageReporters=text-lcov | coveralls",
1925
"test": "jest"
2026
},
2127
"devDependencies": {
@@ -33,6 +39,7 @@
3339
"babel-jest": "^27.1.0",
3440
"benchmark": "^2.1.4",
3541
"benchmarkjs": "^0.1.8",
42+
"coveralls": "^3.1.1",
3643
"eslint": "^7.32.0",
3744
"eslint-config-prettier": "^8.3.0",
3845
"eslint-import-resolver-typescript": "^2.4.0",

src/engine.js

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { interpolateDeep } from './interpolate';
2+
import { defaults } from './options';
3+
import { patch, memoRecord } from './utils';
4+
5+
const createFactEvaluator =
6+
(context, facts, { validator, resolver }, emit) =>
7+
(rule, index) =>
8+
async ([factName, { name, params, path, is }]) => {
9+
emit('debug', {
10+
type: 'STARTING_FACT',
11+
fact: factName,
12+
rule,
13+
index,
14+
params,
15+
});
16+
const fact = facts[factName] || context[factName];
17+
try {
18+
const value = await (typeof fact === 'function' ? fact(params) : fact);
19+
const resolved = path ? resolver(value, path) : value;
20+
emit('debug', {
21+
type: 'EXECUTED_FACT',
22+
fact: factName,
23+
rule,
24+
index,
25+
params,
26+
value,
27+
resolved,
28+
});
29+
try {
30+
const result = await validator(resolved, is);
31+
emit('debug', {
32+
type: 'EVALUATED_FACT',
33+
fact: factName,
34+
rule,
35+
index,
36+
value,
37+
resolved,
38+
is,
39+
result,
40+
});
41+
return {
42+
factName,
43+
name,
44+
value,
45+
resolved,
46+
...result,
47+
};
48+
} catch (error) {
49+
emit('error', {
50+
type: 'FactEvaluationError',
51+
rule,
52+
error,
53+
context,
54+
factName,
55+
value,
56+
resolved,
57+
path,
58+
is,
59+
});
60+
return { error: true };
61+
}
62+
} catch (error) {
63+
emit('error', {
64+
type: 'FactExecutionError',
65+
rule,
66+
error,
67+
context,
68+
factName,
69+
params,
70+
});
71+
return { error: true };
72+
}
73+
};
74+
75+
const createFactmapChecker = (context, facts, options, emit) => {
76+
const evaluator = createFactEvaluator(context, facts, options, emit);
77+
return (rule) => {
78+
return async (factMap, index) => {
79+
emit('debug', {
80+
type: 'STARTING_FACT_MAP',
81+
rule,
82+
index,
83+
});
84+
const results = await Promise.all(
85+
Object.entries(factMap).map(evaluator(rule, index)),
86+
);
87+
return results.reduce(
88+
(acc, { factName, ...rest }) => ({ ...acc, [factName]: rest }),
89+
{},
90+
);
91+
};
92+
};
93+
};
94+
95+
const getRuleResults = (results) => {
96+
return Object.values(results).reduce(
97+
(acc, resultMap) => {
98+
if (acc.error) return acc;
99+
const error = Object.values(resultMap).some(({ error }) => error);
100+
return {
101+
error,
102+
passed:
103+
!error &&
104+
acc.passed &&
105+
Object.values(resultMap).every(({ result }) => result),
106+
};
107+
},
108+
{
109+
passed: true,
110+
error: false,
111+
},
112+
);
113+
};
114+
115+
const getResultsContext = (results) =>
116+
results.reduce(
117+
(acc, r) =>
118+
Object.values(r).reduce(
119+
(a, { name, ...rest }) => (name ? { ...a, [name]: rest } : a),
120+
acc,
121+
),
122+
{ ...results },
123+
);
124+
125+
const createActionExecutor = (actions, opts, emit) => (what, context) =>
126+
Promise.all(
127+
interpolateDeep(what, context, opts.pattern, opts.resolver).map(
128+
async ({ type, params }) => {
129+
try {
130+
if (!actions[type]) throw new Error(`No action found for ${type}`);
131+
await actions[type](params);
132+
} catch (error) {
133+
emit('error', {
134+
type: 'ActionExecutionError',
135+
action: type,
136+
params,
137+
error,
138+
});
139+
}
140+
},
141+
),
142+
);
143+
144+
const createRuleRunner = (context, facts, actions, opts, emit) => {
145+
const checker = createFactmapChecker(context, facts, opts, emit);
146+
const executor = createActionExecutor(actions, opts, emit);
147+
return async ([rule, { when, ...rest }]) => {
148+
const interpolatedRule = interpolateDeep(
149+
when,
150+
context,
151+
opts.pattern,
152+
opts.resolver,
153+
);
154+
emit('debug', {
155+
type: 'STARTING_RULE',
156+
rule,
157+
interpolated: interpolatedRule,
158+
});
159+
const results = await Promise.all(interpolatedRule.map(checker(rule)));
160+
const { passed, error } = getRuleResults(results);
161+
const resultsContext = getResultsContext(results);
162+
emit('debug', {
163+
type: 'FINISHED_RULE',
164+
rule,
165+
results,
166+
passed,
167+
error,
168+
context: resultsContext,
169+
});
170+
if (error) return;
171+
const key = passed ? 'then' : 'otherwise';
172+
const which = rest[key];
173+
if (!which) return;
174+
const nextContext = {
175+
...context,
176+
results: { ...(context.results || {}), ...resultsContext },
177+
};
178+
179+
return Promise.all([
180+
which.when
181+
? createRuleRunner(
182+
nextContext,
183+
facts,
184+
actions,
185+
opts,
186+
emit,
187+
)([`${rule}.${key}`, which])
188+
: null,
189+
which.actions ? executor(which.actions, nextContext) : null,
190+
]);
191+
};
192+
};
193+
194+
export const createRulesEngine = ({
195+
facts = {},
196+
actions = {},
197+
rules = {},
198+
...options
199+
} = {}) => {
200+
options = { ...defaults, ...options };
201+
202+
if (!options.validator) throw new Error('A validator is required');
203+
204+
const eventMap = new Map();
205+
206+
const emit = (event, a) => (eventMap.get(event) || []).forEach((s) => s(a));
207+
208+
const on = (event, subscriber) => {
209+
if (!eventMap.get(event)) eventMap.set(event, new Set());
210+
eventMap.get(event).add(subscriber);
211+
return () => eventMap.get(event).delete(subscriber);
212+
};
213+
214+
return {
215+
setFacts: (next) => (facts = patch(next, facts)),
216+
setActions: (next) => (actions = patch(next, actions)),
217+
setRules: (next) => (rules = patch(next, rules)),
218+
run: async (context = {}) => {
219+
emit('start', { context, facts, rules, actions });
220+
const runner = createRuleRunner(
221+
context,
222+
memoRecord(facts),
223+
actions,
224+
options,
225+
emit,
226+
);
227+
await Promise.all(Object.entries(rules).map(runner));
228+
emit('complete', { context });
229+
},
230+
on,
231+
};
232+
};

index.d.ts renamed to src/index.d.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
type UnaryAsync = (arg: any) => unknown | Promise<unknown>;
1+
type UnaryFunction = (arg: any) => MaybePromise<unknown>;
22

3-
type Facts = Record<string, UnaryAsync>;
4-
type Actions = Record<string, UnaryAsync>;
3+
type Facts = Record<string, UnaryFunction>;
4+
type Actions = Record<string, UnaryFunction>;
55
type Rules = Record<string, Rule>;
6-
type Rule = {
6+
export type Rule = {
77
when: FactMap[];
88
then?: RuleActions | Rule | (Rule & RuleActions);
99
otherwise?: RuleActions | Rule | (Rule & RuleActions);
1010
};
1111

12+
type MaybePromise<T> = T | Promise<T>;
13+
1214
type Action = {
1315
type: string;
1416
params?: unknown;
@@ -29,16 +31,17 @@ interface ValidatorResult {
2931
result: boolean;
3032
}
3133

32-
type Validator = (subject: any, schema: any) => ValidatorResult;
34+
type Validator = (subject: any, schema: any) => MaybePromise<ValidatorResult>;
3335

3436
type Unsubscribe = () => void;
3537

3638
type Options = {
3739
validator: Validator;
3840
facts?: Facts;
39-
actions: Actions;
41+
actions?: Actions;
4042
rules?: Rules;
4143
pattern?: RegExp;
44+
resolver?: (path: string) => any;
4245
};
4346

4447
type StartingFactMapEvent = {
@@ -137,21 +140,15 @@ type PatchFunction<T> = (o: T) => T;
137140

138141
type Patch<T> = PatchFunction<T> | Partial<T>;
139142

140-
interface RulesEngine {
143+
export interface RulesEngine {
141144
setRules(rules: Patch<Rules>): void;
142145
setActions(actions: Patch<Actions>): void;
143146
setFacts(facts: Patch<Facts>): void;
144-
run(context: Context): Promise<void>;
145-
off(
146-
event: 'debug' | 'error' | 'start' | 'complete',
147-
subscriber: Subscriber,
148-
): void;
147+
run(context?: Context): Promise<void>;
149148
on(event: 'debug', subscriber: DebugSubscriber): Unsubscribe;
150149
on(event: 'start', subscriber: StartSubscriber): Unsubscribe;
151150
on(event: 'complete', subscriber: CompleteSubscriber): Unsubscribe;
152151
on(event: 'error', subscriber: ErrorSubscriber): Unsubscribe;
153152
}
154153

155-
declare function createRulesEngine(options: Options): RulesEngine;
156-
157-
export default createRulesEngine;
154+
export function createRulesEngine(options: Options): RulesEngine;

0 commit comments

Comments
 (0)