diff --git a/src/historical.ts b/src/historical.ts index a9c22fd..2853750 100644 --- a/src/historical.ts +++ b/src/historical.ts @@ -180,3 +180,44 @@ export function queryAndCountOrderedEvent( return 0; } } + +/** + * Queries a list of events and returns the index of the most recent event that occurred within a specified time range. + * + * @param events - Array of historical event conditions to check + * @param context - The context object containing the events history + * @param options - Rules engine options with event hash generation capability + * @param from - Optional timestamp (in milliseconds) marking the start of the time range (default: 0) + * @param to - Optional timestamp (in milliseconds) marking the end of the time range (default: Infinity) + * @returns the index of the most recent event. -1 if no event is found + */ +export function queryAndCountMostRecentEvent( + events: Array, + context: Context, + options: RulesEngineOptions, + from = 0, + to = Infinity, +) { + try { + return events.reduce( + (mostRecent, event, index) => { + const eventHash = options.generateEventHash(normalizeEvent(event)); + const contextEvent = context.events[eventHash]; + if (!contextEvent) { + return mostRecent; + } + + const mostRecentTimestamp = contextEvent.timestamps + .filter((t: number) => t >= from && t <= to) + .pop(); + + return mostRecentTimestamp && mostRecentTimestamp > mostRecent.timestamp + ? { index, timestamp: mostRecentTimestamp } + : mostRecent; + }, + { index: -1, timestamp: 0 }, + ).index; + } catch { + return -1; + } +} diff --git a/src/model.ts b/src/model.ts index 9dcf612..7bf4b36 100644 --- a/src/model.ts +++ b/src/model.ts @@ -29,6 +29,7 @@ import { import { checkForHistoricalMatcher, queryAndCountAnyEvent, + queryAndCountMostRecentEvent, queryAndCountOrderedEvent, } from "./historical"; @@ -163,7 +164,15 @@ export function createHistoricalDefinition( evaluate: (context, options) => { let eventCount: number; - if (SearchType.ORDERED === searchType) { + if (SearchType.MOST_RECENT === searchType) { + eventCount = queryAndCountMostRecentEvent( + events, + context, + options, + from, + to, + ); + } else if (SearchType.ORDERED === searchType) { eventCount = queryAndCountOrderedEvent( events, context, diff --git a/src/types/enums.ts b/src/types/enums.ts index 1fb707a..8676cb2 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -38,6 +38,7 @@ export const LogicType = { export const SearchType = { ANY: "any", ORDERED: "ordered", + MOST_RECENT: "mostRecent", }; export type SupportedCondition = diff --git a/src/types/schema.ts b/src/types/schema.ts index 461356e..8a92870 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -34,6 +34,7 @@ export interface RuleSet { export interface Rule { key?: string; + meta?: object; condition: GroupCondition | MatcherCondition | HistoricalCondition; consequences: Array; } diff --git a/test/historical-query.spec.ts b/test/historical-query.spec.ts index a44bbf6..0a08a59 100644 --- a/test/historical-query.spec.ts +++ b/test/historical-query.spec.ts @@ -42,374 +42,575 @@ const CONSEQUENCE: Consequence = { }; describe("rules from AJO", () => { - it("supports historical condition for searchType ANY", () => { - expect( - RulesEngine( - { - version: 1, - rules: [ - { - condition: { - definition: { - conditions: [ - { - definition: { - conditions: [ - { - definition: { - events: [ - { - "iam.eventType": "display", - "iam.id": - "28bea011-e596-4429-b8f7-b5bd630c6743#b45498cb-96d1-417a-81eb-2f09157ad8c6", - }, - ], - matcher: "eq", - value: 0, - from: 1681321309855, - to: 1996681309855, + describe("with searchType any", () => { + it("supports historical condition", () => { + expect( + RulesEngine( + { + version: 1, + rules: [ + { + condition: { + definition: { + conditions: [ + { + definition: { + conditions: [ + { + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": + "28bea011-e596-4429-b8f7-b5bd630c6743#b45498cb-96d1-417a-81eb-2f09157ad8c6", + }, + ], + matcher: "eq", + value: 0, + from: 1681321309855, + to: 1996681309855, + }, + type: "historical", }, - type: "historical", - }, - ], - logic: "and", + ], + logic: "and", + }, + type: "group", }, - type: "group", - }, - ], - logic: "and", + ], + logic: "and", + }, + type: "group", }, - type: "group", + consequences: [CONSEQUENCE], }, - consequences: [CONSEQUENCE], + ], + }, + rulesEngineOptions, + ).execute({ + events: { + display: { + "28bea011-e596-4429-b8f7-b5bd630c6743#b45498cb-96d1-417a-81eb-2f09157ad8c6": + { + event: { + "iam.eventType": "display", + "iam.id": + "28bea011-e596-4429-b8f7-b5bd630c6743#b45498cb-96d1-417a-81eb-2f09157ad8c6", + }, + firstTimestamp: 1695065771698, + timestamp: 1695065771698, + count: 0, + }, }, - ], - }, - rulesEngineOptions, - ).execute({ - events: { - display: { - "28bea011-e596-4429-b8f7-b5bd630c6743#b45498cb-96d1-417a-81eb-2f09157ad8c6": + }, + }), + ).toEqual([[CONSEQUENCE]]); + }); + + it("should return empty consequence for historical condition when count doesn't match with matcher provided", () => { + expect( + RulesEngine( + { + version: 1, + rules: [ { - event: { - "iam.eventType": "display", - "iam.id": - "28bea011-e596-4429-b8f7-b5bd630c6743#b45498cb-96d1-417a-81eb-2f09157ad8c6", + condition: { + definition: { + conditions: [ + { + definition: { + conditions: [ + { + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", + }, + ], + matcher: "eq", + value: 0, + from: 1681321309855, + to: 1996681309855, + }, + type: "historical", + }, + ], + logic: "and", + }, + type: "group", + }, + ], + logic: "and", + }, + type: "group", }, - firstTimestamp: 1695065771698, - timestamp: 1695065771698, - count: 0, + consequences: [CONSEQUENCE], }, + ], }, - }, - }), - ).toEqual([[CONSEQUENCE]]); - }); + rulesEngineOptions, + ).execute({ + events: { + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa","eventType":"display"}': + { + timestamps: [1681321319855], + }, + }, + }), + ).toEqual([]); + }); - it("Should return empty consequence for historical condition when count doesn't match with matcher provided", () => { - expect( - RulesEngine( - { - version: 1, - rules: [ - { - condition: { - definition: { - conditions: [ - { - definition: { - conditions: [ - { - definition: { - events: [ - { - "iam.eventType": "display", - "iam.id": - "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", - }, - ], - matcher: "eq", - value: 0, - from: 1681321309855, - to: 1996681309855, - }, - type: "historical", + it("distinguishes between display and interact events with the same id", () => { + const displayRuleset = { + version: 1, + rules: [ + { + condition: { + definition: { + conditions: [ + { + definition: { + conditions: [ + { + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": "6cd5a8ed", + }, + ], + matcher: "ge", + value: 1, }, - ], - logic: "and", - }, - type: "group", + type: "historical", + }, + ], + logic: "and", }, - ], - logic: "and", - }, - type: "group", + type: "group", + }, + ], + logic: "and", }, - consequences: [CONSEQUENCE], + type: "group", }, - ], - }, - rulesEngineOptions, - ).execute({ - events: { - '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa","eventType":"display"}': - { - timestamps: [1681321319855], - }, - }, - }), - ).toEqual([]); - }); + consequences: [CONSEQUENCE], + }, + ], + }; - it("should return in case count of an event is greater than one and the event is in the date range for ordered search type", () => { - expect( - RulesEngine( - { - version: 1, - rules: [ - { - condition: { - definition: { - conditions: [ - { - definition: { - conditions: [ - { - definition: { - events: [ - { - "iam.eventType": "display", - "iam.id": - "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", - }, - { - "iam.eventType": "interact", - "iam.id": - "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb", - }, - ], - matcher: "ge", - value: 1, - from: 1681321309855, - to: 1996681309855, - searchType: "ordered", - }, - type: "historical", + const interactRuleset: RuleSet = { + version: 1, + rules: [ + { + condition: { + definition: { + conditions: [ + { + definition: { + conditions: [ + { + definition: { + events: [ + { + "iam.eventType": "interact", + "iam.id": "6cd5a8ed", + }, + ], + matcher: "ge", + value: 1, }, - ], - logic: "and", - }, - type: "group", + type: "historical", + }, + ], + logic: "and", }, - ], - logic: "and", - }, - type: "group", + type: "group", + }, + ], + logic: "and", }, - consequences: [CONSEQUENCE], + type: "group", }, - ], - }, - rulesEngineOptions, - ).execute({ - events: { - '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa","eventType":"display"}': - { - timestamps: [1681321319855], + consequences: [CONSEQUENCE], + }, + ], + }; + + expect( + RulesEngine(displayRuleset, rulesEngineOptions).execute({ + events: { + '{"eventId":"6cd5a8ed","eventType":"interact"}': { + timestamps: [1681321939855], }, - '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb","eventType":"interact"}': - { - timestamps: [1681321319855], + }, + }), + ).toEqual([]); + + expect( + RulesEngine(displayRuleset, rulesEngineOptions).execute({ + events: { + '{"eventId":"6cd5a8ed","eventType":"display"}': { + timestamps: [1681321939855], }, - }, - }), - ).toEqual([[CONSEQUENCE]]); + }, + }), + ).toEqual([[CONSEQUENCE]]); + + expect( + RulesEngine(interactRuleset, rulesEngineOptions).execute({ + events: { + '{"eventId":"6cd5a8ed","eventType":"display"}': { + timestamps: [1681321939855], + }, + }, + }), + ).toEqual([]); + + expect( + RulesEngine(interactRuleset, rulesEngineOptions).execute({ + events: { + '{"eventId":"6cd5a8ed","eventType":"interact"}': { + timestamps: [1681321939855], + }, + }, + }), + ).toEqual([[CONSEQUENCE]]); + }); }); - it("Should return 0 if the count for any event is ever zero for ordered search type", () => { - expect( - RulesEngine( - { - version: 1, - rules: [ - { - condition: { - definition: { - conditions: [ - { - definition: { - conditions: [ - { - definition: { - events: [ - { - "iam.eventType": "display", - "iam.id": - "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", - }, - { - "iam.eventType": "interact", - "iam.id": - "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb", - }, - ], - matcher: "eq", - value: 1, - from: 1681321309855, - to: 1996681309855, - searchType: "ordered", + describe("with searchType ordered", () => { + it("should return 1 for events that occured in the specified order", () => { + expect( + RulesEngine( + { + version: 1, + rules: [ + { + condition: { + definition: { + conditions: [ + { + definition: { + conditions: [ + { + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", + }, + { + "iam.eventType": "interact", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb", + }, + ], + matcher: "ge", + value: 1, + from: 1681321309855, + to: 1996681309855, + searchType: "ordered", + }, + type: "historical", }, - type: "historical", - }, - ], - logic: "and", + ], + logic: "and", + }, + type: "group", }, - type: "group", - }, - ], - logic: "and", + ], + logic: "and", + }, + type: "group", }, - type: "group", + consequences: [CONSEQUENCE], }, - consequences: [CONSEQUENCE], - }, - ], - }, - rulesEngineOptions, - ).execute({ - events: { - display: { - "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa": + ], + }, + rulesEngineOptions, + ).execute({ + events: { + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa","eventType":"display"}': { - event: { - "iam.eventType": "display", - "iam.id": - "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", - }, - timestamp: 1681321319855, - count: 1, + timestamps: [1681321319855], + }, + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb","eventType":"interact"}': + { + timestamps: [1681321319855], }, }, - }, - }), - ).toEqual([]); - }); + }), + ).toEqual([[CONSEQUENCE]]); + }); - it("distinguishes between display and interact events with the same id", () => { - const displayRuleset = { - version: 1, - rules: [ - { - condition: { - definition: { - conditions: [ - { + it("Should return 0 for events the did not occured in the specified order", () => { + expect( + RulesEngine( + { + version: 1, + rules: [ + { + condition: { definition: { conditions: [ { definition: { - events: [ + conditions: [ { - "iam.eventType": "display", - "iam.id": "6cd5a8ed", + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", + }, + { + "iam.eventType": "interact", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb", + }, + ], + matcher: "eq", + value: 1, + from: 1681321309855, + to: 1996681309855, + searchType: "ordered", + }, + type: "historical", }, ], - matcher: "ge", - value: 1, + logic: "and", }, - type: "historical", + type: "group", }, ], logic: "and", }, type: "group", }, - ], - logic: "and", + consequences: [CONSEQUENCE], + }, + ], + }, + rulesEngineOptions, + ).execute({ + events: { + display: { + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa": + { + event: { + "iam.eventType": "display", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", + }, + timestamp: 1681321319855, + count: 1, + }, }, - type: "group", }, - consequences: [CONSEQUENCE], - }, - ], - }; + }), + ).toEqual([]); + }); + }); - const interactRuleset: RuleSet = { - version: 1, - rules: [ - { - condition: { - definition: { - conditions: [ - { + describe("with searchType mostRecent", () => { + it("Should return the index of the most recent event form a list of events", () => { + expect( + RulesEngine( + { + version: 1, + rules: [ + { + condition: { definition: { conditions: [ { definition: { - events: [ + conditions: [ { - "iam.eventType": "interact", - "iam.id": "6cd5a8ed", + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", + }, + { + "iam.eventType": "interact", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb", + }, + ], + matcher: "eq", + value: 1, + from: 1681321309855, + to: 1996681309855, + searchType: "mostRecent", + }, + type: "historical", }, ], - matcher: "ge", - value: 1, + logic: "and", }, - type: "historical", + type: "group", }, ], logic: "and", }, type: "group", }, - ], - logic: "and", - }, - type: "group", + consequences: [CONSEQUENCE], + }, + ], }, - consequences: [CONSEQUENCE], - }, - ], - }; - - expect( - RulesEngine(displayRuleset, rulesEngineOptions).execute({ - events: { - '{"eventId":"6cd5a8ed","eventType":"interact"}': { - timestamps: [1681321939855], + rulesEngineOptions, + ).execute({ + events: { + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa","eventType":"display"}': + { + timestamps: [1681321309855], + }, + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb","eventType":"interact"}': + { + timestamps: [1681321309859], + }, }, - }, - }), - ).toEqual([]); + }), + ).toEqual([[CONSEQUENCE]]); + }); - expect( - RulesEngine(displayRuleset, rulesEngineOptions).execute({ - events: { - '{"eventId":"6cd5a8ed","eventType":"display"}': { - timestamps: [1681321939855], + it("Should return Infinity if no event is matching the given time range", () => { + expect( + RulesEngine( + { + version: 1, + rules: [ + { + condition: { + definition: { + conditions: [ + { + definition: { + conditions: [ + { + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", + }, + { + "iam.eventType": "interact", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb", + }, + ], + matcher: "eq", + value: 1, + from: 1681321309855, + to: 1996681309855, + searchType: "mostRecent", + }, + type: "historical", + }, + ], + logic: "and", + }, + type: "group", + }, + ], + logic: "and", + }, + type: "group", + }, + consequences: [CONSEQUENCE], + }, + ], }, - }, - }), - ).toEqual([[CONSEQUENCE]]); - - expect( - RulesEngine(interactRuleset, rulesEngineOptions).execute({ - events: { - '{"eventId":"6cd5a8ed","eventType":"display"}': { - timestamps: [1681321939855], + rulesEngineOptions, + ).execute({ + events: { + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa","eventType":"display"}': + { + timestamps: [1], + }, + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb","eventType":"interact"}': + { + timestamps: [1], + }, }, - }, - }), - ).toEqual([]); + }), + ).toEqual([]); + }); - expect( - RulesEngine(interactRuleset, rulesEngineOptions).execute({ - events: { - '{"eventId":"6cd5a8ed","eventType":"interact"}': { - timestamps: [1681321939855], + it("Should return Infinity if none of the provided events is inside the event history", () => { + expect( + RulesEngine( + { + version: 1, + rules: [ + { + condition: { + definition: { + conditions: [ + { + definition: { + conditions: [ + { + definition: { + events: [ + { + "iam.eventType": "display", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3aa", + }, + { + "iam.eventType": "interact", + "iam.id": + "6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3bb", + }, + ], + matcher: "eq", + value: 1, + from: 1681321309855, + to: 1996681309855, + searchType: "mostRecent", + }, + type: "historical", + }, + ], + logic: "and", + }, + type: "group", + }, + ], + logic: "and", + }, + type: "group", + }, + consequences: [CONSEQUENCE], + }, + ], + }, + rulesEngineOptions, + ).execute({ + events: { + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3cc","eventType":"display"}': + { + timestamps: [1681321309855], + }, + '{"eventId":"6cd5a8ed-e183-48b7-a0ef-657a4467df74#0477a309-6f63-4638-b729-ab51cf5dd3dd","eventType":"interact"}': + { + timestamps: [1681321309855], + }, }, - }, - }), - ).toEqual([[CONSEQUENCE]]); + }), + ).toEqual([]); + }); }); }); diff --git a/test/parser.spec.ts b/test/parser.spec.ts index 4b53406..0f149ea 100644 --- a/test/parser.spec.ts +++ b/test/parser.spec.ts @@ -65,6 +65,9 @@ const RULE_SET_WITH_RULE_KEY: RuleSet = { rules: [ { key: "rule1", + meta: { + ruleType: "card", + }, condition: { definition: { key: "foo",