diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 5069cfc..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -docs/api \ No newline at end of file diff --git a/docs/docs/intro.md b/docs/docs/intro.md deleted file mode 100644 index 387036b..0000000 --- a/docs/docs/intro.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -sidebar_position: 0 -title: Overview -slug: / ---- - -# Introduction - -This is the package documentation diff --git a/docs/docs/jsonexpr.md b/docs/docs/jsonexpr.md deleted file mode 100644 index 7ba75ff..0000000 --- a/docs/docs/jsonexpr.md +++ /dev/null @@ -1,192 +0,0 @@ -# JSONExpressions (jsonexpr) -JSONExpressions (jsonexpr) is a simple logical language that can be used to specify rules for data manipulation and evaluation. It is a standardized way of expressing logical statements in a JSON format, and is intended to be used as a building block for creating more complex systems that need to perform logical operations on data. - -JSONExpressions borrows heavily from [JSONLogic](https://jsonlogic.com), which is another data interchange format for expressing logical operations in JSON. - -Some of the key concepts that JSONExpressions borrows from JSONLogic include: - -* The use of JSON as the base data interchange format -* The ability to specify logical operations using a standardized syntax -* The support for variables and functions in logical expressions -* The use of input data to determine the values of variables in the logical expressions - -There are also some notable differences between JSONExpressions and JSONLogic. For example, JSONExpressions introduces additional operators and functions, and has a slightly different syntax for specifying logical expressions. - -Overall, JSONExpressions builds upon the foundation established by JSONLogic, providing a more powerful and flexible way to specify logical expressions in JSON. - -## Scope - -This specification defines JSONExpressions, a data interchange format for expressing logical operations. - -## Definitions - -* JSON: The JavaScript Object Notation (JSON) data interchange format, as defined in ECMA-404. -* `object`: A JSON object, as defined in ECMA-404. -* `array`: A JSON array, as defined in ECMA-404. -* `boolean`: A JSON boolean, as defined in ECMA-404. -* `null`: A JSON null, as defined in ECMA-404. -* `number`: A JSON number, as defined in ECMA-404. -* `string`: A JSON string, as defined in ECMA-404. - -## Operators - -JSONRule supports the following operators: - -### `var` -An operator to access the values of specific elements in arrays or objects. A data accessor is a string that specifies the path to the element in the data. - -There are two types of data accessors: - -1. Object accessors: An object accessor is a string that specifies the name of a property in an object. The name is specified as a string. -2. Array accessors: An array accessor is a string that specifies the index of an element in an array. The index is specified as a positive integer. For example, "0" is the first element of the array, "1" is the second element, and so on. - -Here is an example of a JSONExpressions document that uses data accessors: -```json -{ - "==": [ - { "var": "x.0" }, - { "var": "y.foo" } - ] -} -``` - -In this example, the `==` operator compares the value of the first element of the array `x` to the value of the property foo in the object `y`. The values of `x` and $y would be specified in the input data passed to the JSONExpressions document. - -Data accessors can be nested to access elements within arrays or objects. For example, the following data accessor would access the `bar` property of the second element of the `baz` array: - -``` -"x.baz.1.bar" -``` - -Note that data accessors are not the same as variables, which are used to reference the values of variables in the input data. Data accessors are used to access specific elements in the input data itself. - -### `if` -An operator that takes three arguments: a boolean expression, a value to return if the boolean expression is true, and a value to return if the boolean expression is false. - -For example: - -```json -{ - "if": [ - true, - "foo", - "bar" - ] -} -``` -In this example, the if operator takes three arguments: - -* A boolean expression: `true` -* A value to return if the boolean expression is `true`: `"foo"` -* A value to return if the boolean expression is `false`: `"bar"` - -Since the boolean expression is true in this example, the result of the if operator would be "foo". - -### `and` -An array of boolean expressions, which returns `true` if all of the expressions are `true`, and `false` otherwise. - -For example: - -```json -{ "and": [true, false, true] } -``` - -This expression would return `false`, because not all of the boolean expressions in the array are `true`. - -### `or` -An array of boolean expressions, which returns true if at least one of the expressions is `true`, and `false` otherwise. -For example: - -```json -{ "or": [false, false, true] } -``` - -This expression would return `true`, because at least one of the boolean expressions in the array is `true`. - -### `not` -A boolean expression, which returns the negation of the expression. - -For example: - -```json -{ "not": true } -``` - -This expression would return `false`, because the negation of `true` is `false`. - -### `==` -An array of two expressions, which returns `true` if the expressions are equal, and `false` otherwise. - -For example: - -```json -{ "==": [1, 1] } -``` - -This expression would return `true`, because the two expressions (1 and 1) are equal. - -### `!=` -An array of two expressions, which returns `true` if the expressions are not equal, and `false` otherwise. - -For example: - -```json -{ "!=": [1, 2] } -``` - -This expression would return `true`, because the two expressions (1 and 2) are not equal. - -### `>` -An array of two expressions, which returns `true` if the first expression is greater than the second expression, and `false` otherwise. - -For example: - -```json -{ ">": [2, 1] } -``` - -This expression would return `true`, because the first expression (2) is greater than the second expression (1). - -### `>=` -An array of two expressions, which returns `true` if the first expression is greater than or equal to the second expression, and `false` otherwise. - -For example: - -```json -{ ">=": [1, 1] } -``` - -This expression would return `true`, because the first expression (1) is equal to the second expression (1). - -### `<` -An array of two expressions, which returns `true` if the first expression is less than the second expression, and `false` otherwise. - -For example: - -```json -{ "<": [1, 2] } -``` - -This expression would return `true`, because the first expression (1) is less than the second expression (2). - -### `<=` -An array of two expressions, which returns `true` if the first expression is less than or equal to the second expression, and `false` otherwise. - -For example: - -```json -{ "<=": [1, 1] } -``` - -This expression would return `true`, because the first expression (1) is equal to the second expression (1). - -### Input data - -JSONExpressions supports the use of input data in expressions. The input data is a JSON object that contains the values of the variables used in the JSONExpressions document. - -### Evaluation -To evaluate a JSONExpressions document, the following steps should be taken: - -1. Replace all occurrences of variables in the JSONLogic document with the corresponding values from the input data. -2. Evaluate all functions and operators in the JSONLogic document, following the rules defined in the specification. -3. Return the resulting value. \ No newline at end of file diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts deleted file mode 100644 index 8239787..0000000 --- a/docs/docusaurus.config.ts +++ /dev/null @@ -1,97 +0,0 @@ -import path from 'node:path' -import type * as Preset from '@docusaurus/preset-classic' -import type { Config } from '@docusaurus/types' -import { themes } from 'prism-react-renderer' -import packageJSON from '../package.json' - -const { description, homepage, name, repository } = packageJSON - -const [organizationName, projectName] = name.replace('@', '').split('/') -const url = new URL(homepage) - -const config: Config = { - title: name, - tagline: description, - url: url.origin, - baseUrl: url.pathname, - trailingSlash: false, - onBrokenLinks: 'warn', - onBrokenMarkdownLinks: 'warn', - favicon: 'img/favicon.ico', - - organizationName, - projectName, - - i18n: { - defaultLocale: 'en', - locales: ['en'], - }, - plugins: [ - [ - 'docusaurus-plugin-typedoc-api', - { - projectRoot: path.join(__dirname, '..'), - packages: ['.'], - }, - ], - ], - - presets: [ - [ - '@docusaurus/preset-classic', - { - docs: { - routeBasePath: '/', - sidebarPath: './sidebars.ts', - }, - blog: false, - theme: { - customCss: './src/css/custom.css', - }, - } satisfies Preset.Options, - ], - ], - - themeConfig: { - navbar: { - title: name, - logo: { - alt: 'Logo', - src: 'img/logo.png', - srcDark: 'img/logow.png', - }, - items: [ - { - type: 'doc', - docId: 'intro', - position: 'left', - label: 'Docs', - }, - { - to: 'api', - label: 'API', - position: 'left', - }, - { - href: repository.url, - label: 'GitHub', - position: 'right', - }, - ], - } satisfies Preset.ThemeConfig, - footer: { - style: 'dark', - links: [], - copyright: `Copyright © ${new Date().getFullYear()} SkyLeague Technologies B.V.`, - }, - prism: { - theme: themes.github, - darkTheme: themes.dracula, - }, - }, - future: { - experimental_faster: true, - }, -} - -export default config diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index b30bf84..0000000 --- a/docs/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@skyleague/documentation", - "private": true, - "scripts": { - "docusaurus": "docusaurus", - "start": "docusaurus start", - "prebuild": "npm install", - "build": "docusaurus build --out-dir=../.docs", - "swizzle": "docusaurus swizzle", - "deploy": "docusaurus deploy", - "clear": "docusaurus clear", - "serve": "docusaurus serve --dir=../.docs", - "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" - }, - "dependencies": { - "@docusaurus/core": "^3.6.3", - "@docusaurus/preset-classic": "^3.6.3", - "@docusaurus/faster": "^3.6.3", - "@mdx-js/react": "^3.1.0", - "prism-react-renderer": "^2.4.1", - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "^3.6.3", - "docusaurus-plugin-typedoc-api": "^4.4.0" - }, - "browserslist": { - "production": [">0.5%", "not dead", "not op_mini all"], - "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] - }, - "engines": { - "node": ">=22" - } -} diff --git a/docs/sidebars.ts b/docs/sidebars.ts deleted file mode 100644 index 20abb29..0000000 --- a/docs/sidebars.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SidebarsConfig } from '@docusaurus/plugin-content-docs' - -const sidebars: SidebarsConfig = { - autogenerated: [{ type: 'autogenerated', dirName: '.' }], -} - -export default sidebars diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css deleted file mode 100644 index 9a4d9ac..0000000 --- a/docs/src/css/custom.css +++ /dev/null @@ -1,22 +0,0 @@ -:root { - --ifm-color-primary: hsl(217, 63%, 46%); - --ifm-color-primary-dark: hsl(217, 63%, 44%); - --ifm-color-primary-darker: hsl(217, 63%, 42%); - --ifm-color-primary-darkest: hsl(217, 63%, 40%) b; - --ifm-color-primary-light: hsl(217, 63%, 48%); - --ifm-color-primary-lighter: hsl(217, 63%, 50%); - --ifm-color-primary-lightest: hsl(217, 63%, 52%); - --ifm-code-font-size: 95%; - --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); -} - -[data-theme="dark"] { - --ifm-color-primary: #eaa22f; - --ifm-color-primary-dark: hsl(217, 63%, 46%); - --ifm-color-primary-darker: hsl(217, 63%, 44%); - --ifm-color-primary-darkest: hsl(217, 63%, 42%); - --ifm-color-primary-light: hsl(217, 54%, 73%); - --ifm-color-primary-lighter: hsl(217, 54%, 75%); - --ifm-color-primary-lightest: hsl(217, 54%, 77%); - --docusaurus-highlighted-code-line-bg: #232933; -} diff --git a/docs/static/img/favicon.ico b/docs/static/img/favicon.ico deleted file mode 100644 index ae636a0..0000000 Binary files a/docs/static/img/favicon.ico and /dev/null differ diff --git a/docs/static/img/logo.png b/docs/static/img/logo.png deleted file mode 100644 index 70b3ca9..0000000 Binary files a/docs/static/img/logo.png and /dev/null differ diff --git a/docs/static/img/logow.png b/docs/static/img/logow.png deleted file mode 100644 index 07253e2..0000000 Binary files a/docs/static/img/logow.png and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 0bfe4da..84c140b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "@skyleague/node-standards": "^11.0.1", "@skyleague/therefore": "^7.15.1", "typescript": "^5.8.3", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^3.24.3" }, "engines": { "node": ">=22" @@ -31,8 +32,8 @@ "@docusaurus/core": "^3.6.3", "@docusaurus/faster": "^3.6.3", "@docusaurus/preset-classic": "^3.6.3", - "@mdx-js/react": "^3.0.1", - "prism-react-renderer": "^2.3.1", + "@mdx-js/react": "^3.1.0", + "prism-react-renderer": "^2.4.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -41,7 +42,7 @@ "docusaurus-plugin-typedoc-api": "^4.4.0" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@algolia/autocomplete-core": { @@ -24726,6 +24727,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 7a0b134..78f3f2c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "types": "./.dist/index.d.ts", "sideEffects": false, "node-standards": { - "extends": ["library", "docusaurus"] + "extends": ["library"] }, "engines": { "node": ">=22" @@ -19,7 +19,6 @@ "files": [".dist", "package.json"], "scripts": { "build": "tsc -p tsconfig.dist.json", - "build:docs": "npm run --prefix=docs build", "check:coverage": "vitest run --coverage=true", "check:project": "node-standards lint", "check:types": "tsc -p tsconfig.json", @@ -37,7 +36,8 @@ "@skyleague/node-standards": "^11.0.1", "@skyleague/therefore": "^7.15.1", "typescript": "^5.8.3", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^3.24.3" }, "publishConfig": { "access": "public", @@ -49,6 +49,5 @@ ".": "./.dist/index.js", "./package.json": "./package.json", "./*.js": "./.dist/*.js" - }, - "workspaces": ["docs"] + } } diff --git a/src/engine/policy.ts b/src/engine/policy.ts index e052552..5c0c90f 100644 --- a/src/engine/policy.ts +++ b/src/engine/policy.ts @@ -121,8 +121,9 @@ export function $policy>( ) const outputNodes = allNodes.filter((f) => f._type !== 'fact' && f.name !== undefined) - const properties = Object.fromEntries(inputNodes.map((e) => [e.name, e.expr('definition')])) - const outputExpression = Object.fromEntries(outputNodes.map((f) => [f.name, f.expr('expression')])) + let cachedProperties: Record | undefined + let cachedOutputExpression: Record | undefined + return { evaluate: ((input: Record) => { const ctx = new EvaluationContext(input) @@ -137,13 +138,19 @@ export function $policy>( return { input: input, output: ctx.state } }) as Policy, OutputFromFacts>['evaluate'], expr: () => { - const input = properties + if (cachedProperties === undefined) { + cachedProperties = Object.fromEntries(inputNodes.map((e) => [e.name, e.expr('definition')])) + } + if (cachedOutputExpression === undefined) { + cachedOutputExpression = Object.fromEntries(outputNodes.map((f) => [f.name, f.expr('expression')])) + } + return { meta: { version, }, - input, - output: outputExpression, + input: cachedProperties, + output: cachedOutputExpression, } }, } diff --git a/src/expressions/input.spec.ts b/src/expressions/input.spec.ts index f81969f..d4d18ea 100644 --- a/src/expressions/input.spec.ts +++ b/src/expressions/input.spec.ts @@ -8,6 +8,7 @@ import { $policy } from '../engine/policy.js' import { forAll, tuple } from '@skyleague/axioms' import { arbitrary } from '@skyleague/therefore' import { describe, expect, it } from 'vitest' +import { z } from 'zod' describe('fact', () => { it('simple single one is used as input', () => { @@ -18,41 +19,60 @@ describe('fact', () => { }) expect(policy.expr()).toMatchInlineSnapshot(` - { - "input": { - "person": { - "$schema": "http://json-schema.org/draft-07/schema#", - "additionalProperties": true, - "properties": { - "age": { - "type": "number", - }, - "birthDate": { - "type": "string", - }, - "firstName": { - "type": "string", - }, - "lastName": { - "type": "string", - }, + { + "input": { + "person": { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": true, + "properties": { + "age": { + "type": "number", + }, + "birthDate": { + "type": "string", + }, + "firstName": { + "type": "string", + }, + "lastName": { + "type": "string", }, - "required": [ - "firstName", - "lastName", - "birthDate", - "age", - ], - "title": "Person", - "type": "object", }, + "required": [ + "firstName", + "lastName", + "birthDate", + "age", + ], + "title": "Person", + "type": "object", }, - "meta": { - "version": "1.0.0", - }, - "output": {}, - } - `) + }, + "meta": { + "version": "1.0.0", + }, + "output": {}, + } + `) + }) + + it('simple single one is used as input - zod', () => { + const zodPerson = z.object({ + firstName: z.string(), + lastName: z.string(), + birthDate: z.string(), + age: z.number(), + }) + zodPerson.safeParse + const person = $fact(zodPerson, 'person') + const policy = $policy({ person }) + forAll(arbitrary(Person), (p) => { + expect(policy.evaluate({ person: p }).input.person).toEqual(p) + }) + + expect(() => policy.expr()).toThrowErrorMatchingInlineSnapshot( + '[Error: Cannot infer definition for fact with zod schema]', + ) }) it('simple multiple is used as input', () => { diff --git a/src/expressions/input.ts b/src/expressions/input.ts index dd8e50f..6b7dfd3 100644 --- a/src/expressions/input.ts +++ b/src/expressions/input.ts @@ -1,10 +1,8 @@ -import type { DefinitionType, FactExpression, InferExpressionType, InputExpression, LiteralExpression } from '../engine/types.js' -import type { FromExpr } from '../json/jsonexpr.type.js' - +import { inspect } from 'node:util' import { JSONPath, type JSONPathValue } from '@skyleague/jsonpath' import type { Schema } from '@skyleague/therefore' - -import { inspect } from 'node:util' +import type { DefinitionType, FactExpression, InferExpressionType, InputExpression, LiteralExpression } from '../engine/types.js' +import type { FromExpr } from '../json/jsonexpr.type.js' // biome-ignore lint/suspicious/noExplicitAny: this is needed for greedy matching export interface Fact extends FactExpression { @@ -13,7 +11,60 @@ export interface Fact extends FactExpress _type: 'fact' } -export function $fact(schema: Pick, 'schema' | 'is'>, name: Name): Fact { +export function $fact( + schema: { + safeParse: (x: unknown) => + | { + success: true + data: T + error?: never + } + | { + success: false + error: unknown + data?: never + } + }, + name: Name, +): Fact +export function $fact(schema: Pick, 'schema' | 'is'>, name: Name): Fact +export function $fact( + schema: + | Pick, 'schema' | 'is'> + | { + safeParse: (x: unknown) => + | { + success: true + data: T + error?: never + } + | { + success: false + error: unknown + data?: never + } + }, + name: Name, +): Fact { + if ('safeParse' in schema) { + return { + _type: 'fact', + dependsOn: [], + name: name, + fn: ((_: unknown, ctx: { input: Record }) => { + return ctx.input[name] + }) as Fact['fn'], + expr: (definition: DefinitionType): InferExpressionType => { + if (definition === 'definition') { + throw new Error('Cannot infer definition for fact with zod schema') + } + return { fact: name.toString() } as unknown as InferExpressionType + }, + [inspect.custom]() { + return `$fact({schema: }, "${name}")` + }, + } + } return { _type: 'fact', dependsOn: [], diff --git a/test/policies.spec.ts b/test/policies.spec.ts index 633ff08..cd0577c 100644 --- a/test/policies.spec.ts +++ b/test/policies.spec.ts @@ -27,7 +27,7 @@ describe('arbitrary', () => { tests: 100000, }, ) - }) + }, 100000) }) describe('any string starts with', () => {